Add 3D Object & 3D World generation to the webapp (#1756)

Adds two new create pages for 3D generation, 3D Object (meshes) and 3D
World (gaussian splats), wired up to the new omni-gen mesh/splat
endpoints, plus the navigation, icons, and polish to go with them.

### New create pages
- 3D Object (`/create-object`): turn a prompt or image into a 3D model.
- 3D World (`/create-world`): turn a prompt or image into an explorable
world.
- Both mirror the existing Create Image/Video experience: prompt box
with model selector and per-model options in the bottom bar, and a live
feed of in-progress, completed, and failed generations.
- Options adapt to the selected model, e.g. output type, polygon type,
geometry/texture quality, face count, PBR and texture toggles,
multi-view reference angles (front/back/left/right), mesh-file input,
reference image/video, panorama, and prompt-only where supported.
- Multi-view and reference slots support the same Upload /
Pick-from-library flow (desktop hover and mobile) as the image rows.
- Completed cards show the model's cover screenshot; clicking one opens
it in the interactive 3D viewer.

### Navigation and discovery
- Sidebar reorganized into two groups: Create (Image, Video, Audio, 3D
Object, 3D World) and a new Studio group (Edit Image, Edit 3D, Edit
Video, BG Change, Moodboard).
- Added 3D Object and 3D World cards to the Home page.
- Added breadcrumbs for the new pages.
- Added a My Library button to the top bar, next to the credits and
task-queue chips.

### Model icons
- Restored the Hunyuan (Tencent) and World Labs provider icons.
- Added Tripo, Meshy, and Rodin (Hyper3D) icons.
- Applied to both the webapp and desktop app.

### Fixes and polish
- Fixed the 3D-model icons showing a generic placeholder (missing cyan
color plus creator mappings).
- Fixed the Upload / Pick-from-library hover popover being clipped
inside glass panels.
- Reference-image rows in the 3D pages now align their fields to the
right with the title on the left, matching the other create pages.
- Prevented 3D-model cover screenshots from showing up as their own "3D
object" cards across the 3D feeds, the Library page, and the gallery
picker.
This commit is contained in:
BFlat authored and GitHub committed 2026-07-08 23:03:11 +07:00
1 parent bec8c7ede4
commit d538bc2a68
40 files changed
+3309 -51

No files matched your search

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 134.36 135.45"><defs><style>.cls-1{fill:#fff;}</style></defs><g id="Layer_1-2"><path class="cls-1" d="M43.48,35.1c6.03-.05,11.65-1.42,16.41-3.77,6.79-4.43,10.34-10.91,8.32-18.93C66.63,6.14,60.82.22,52.99,0,29.78-.61,7.27,27.85,6.37,52.67c9.08-11.39,22.16-17.45,37.11-17.58ZM102.56,75.86c7.21-12.31,8.6-28.65.63-41.29-7.37-5.64-18.16-6.36-27.03-5.54,7.39,3.49,13.52,7.4,17.96,13.73,7.07,9.52,9.38,20.88,8.44,33.1ZM21.31,55.53c5.16-8.31,12.73-13.82,21.44-17.82-15.34-.32-28.83,7.33-36.66,19.8.07,9.84,3.05,18.72,9.08,26.22-1.43-10.35.39-19.89,6.14-28.2ZM131.1,101.8c3.16-6.46,3.46-13.31,3.19-20.31-2.23-17.6-10.95-33.91-26.59-44.3,4.91,12.97,4.23,26.92-2.61,39.15-2.97,5.31-5.03,11.12-5.75,17.05.09,8.65,4.58,15.39,12.26,17.49s15.7-1.35,19.5-9.1ZM78.37,64.17c-1.95-7.81-6.43-13.46-13-15.54,3.95,4.17,6.12,8.23,5.71,13.26-.34,4.22-3.26,7.44-6.85,10.65,5.45-1.51,10.08-4.25,14.13-8.37ZM49.88,93.89c9.44,2.63,19.59-.35,25.02-8.75,3.48-5.39,4.62-11.78,3.81-18.3-8.54,7.51-18.31,9.82-28.97,7.96l.14,19.09ZM70.77,129.26c-14.67-2.16-26.48-10.66-33.83-23.32-2.91-5-6.94-9.21-11.48-12.36-7.78-4.01-16.06-3.09-21.46,3.2-4.88,5.69-5.47,14.34-.74,20.89,13.45,18.63,45.84,22.83,67.52,11.59ZM93.06,106.37c-8.34,5.81-16.34,9.2-25.74,8.51-10.41.17-19.39-3.81-27.88-9.62,7.62,13.32,20.74,21.39,35.62,21.96,8.15-4.92,14.6-11.66,18-20.84Z"/><path d="M131.1,101.8c-3.79,7.75-12.08,11.13-19.5,9.1s-12.17-8.84-12.26-17.49c.73-5.93,2.78-11.74,5.75-17.05,6.84-12.24,7.51-26.18,2.61-39.15,15.63,10.39,24.36,26.7,26.59,44.3.27,7-.03,13.85-3.19,20.31Z"/><path d="M70.77,129.26c-21.68,11.24-54.06,7.04-67.52-11.59-4.73-6.55-4.14-15.2.74-20.89,5.4-6.29,13.68-7.21,21.46-3.2,4.55,3.15,8.58,7.36,11.48,12.36,7.36,12.65,19.16,21.15,33.83,23.32Z"/><path d="M43.48,35.1c-14.95.13-28.03,6.19-37.11,17.58C7.27,27.85,29.78-.61,52.99,0c7.83.21,13.64,6.13,15.22,12.39,2.03,8.01-1.53,14.5-8.32,18.93-4.77,2.35-10.38,3.72-16.41,3.77Z"/><path d="M49.88,93.89l-.14-19.09c10.65,1.86,20.43-.45,28.97-7.96.81,6.52-.32,12.91-3.81,18.3-5.43,8.4-15.58,11.38-25.02,8.75Z"/><path d="M93.06,106.37c-3.4,9.18-9.85,15.92-18,20.84-14.88-.56-28-8.63-35.62-21.96,8.5,5.82,17.47,9.79,27.88,9.62,9.4.69,17.4-2.7,25.74-8.51Z"/><path d="M102.56,75.86c.94-12.21-1.37-23.57-8.44-33.1-4.44-6.34-10.57-10.25-17.96-13.73,8.87-.82,19.67-.11,27.03,5.54,7.97,12.64,6.57,28.98-.63,41.29Z"/><path d="M21.31,55.53c-5.75,8.32-7.56,17.85-6.14,28.2-6.03-7.5-9.02-16.38-9.08-26.22,7.83-12.48,21.31-20.13,36.66-19.8-8.71,4-16.28,9.51-21.44,17.82Z"/><path d="M78.37,64.17c-4.06,4.12-8.68,6.86-14.13,8.37,3.58-3.21,6.51-6.43,6.85-10.65.41-5.03-1.76-9.09-5.71-13.26,6.56,2.08,11.05,7.73,13,15.54Z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Meshy</title><path d="M3.214.358l.59 1.606 1.597.595-1.596.594-.59 1.607-.591-1.607-1.596-.594 1.596-.595.59-1.606zM16.3 1.691a8.04 8.04 0 012.178.641c6.94 2.96 6.273 11.893.308 11.306l-.008-.007c.09.247.177.51.25.779.803 2.395-2.534 3.633-4.663 3.785l.138-.078c2.107-1.3 2.17-6.033-.175-5.56-.855.303-1.641 1.093-2 2.503a6.199 6.199 0 00-.093.436 25.87 25.87 0 00-.102.614c-.147.94-.32 2.038-.821 2.66-.578.041-1.153.043-1.711.001.737-.577 1.098-1.385 1.377-2.584-3.215.395-6.084-.384-6.257-4.094.17.809.509 1.431.972 1.896.267.133.61.252 1.043.345.911.196 1.928.07 2.646-.116.36.237.648.518.823.843.23-.027.464-.061.7-.102a4.523 4.523 0 00-.01-.197 3.115 3.115 0 01-.06-.209c-.108-.395-.278-1.023-.953-1.476-.391-.261-.76-.609-.696-1.075.064-.459.478-.731.93-.84 2.84-.683 4.568-2.988 5.45-5.648C14.988 6.75 14.129 8 13.032 8.947c-2.062 1.781-5.857 1.971-7.106.487.364.217.776.359 1.219.431-.299-.122-.54-.291-.665-.52 3.221.894 6.802-2.252 8.148-6.784-2.963-.128-6.335 1.44-8.25 4.01 2.084-3.387 6.04-5.44 9.916-4.88h.007zm2.57 5.476c-.357 1.21-.224 2.564.444 3.459.058.078.121.154.188.225l-.89.809c-1.327-.571-1.541-2.22-.99-3.741a5.04 5.04 0 011.32-1.997 3.822 3.822 0 011.195-.747l.003.004a3.511 3.511 0 00-.559.574c-.322.415-.56.902-.711 1.414z"></path><path d="M20.352 14.028c6.788 3.227-.077 9.51-10.095 8.441-.36-.033-.72-.08-1.077-.142 3.05-.146 5.386-.591 7.887-2.766-5.111 3.23-14.152 2.117-12.294-1.535-.236 3.143 10.482 2.832 15.062-1.501-.73 2.168-1.945 3.664-3.423 4.62 3.978-1.476 6.496-4.16 3.94-7.117zM7.742 8.427s.236.142.573-.039c.546-.292.673-2.054.673-3.083 0-.278-.342-.283-.342-.283l-.029.003c-.398.042-.704.264-.682.495.079.862-.071 1.94-.27 2.64-.028.1.003.192.077.267zM11.395 6.972c-.338.181-.573.039-.573.039a.263.263 0 01-.078-.267c.2-.7.349-1.778.27-2.64-.021-.231.285-.453.683-.495.01 0 .019-.002.028-.003 0 0 .343.005.343.283 0 1.029-.128 2.79-.673 3.083zM1.056 5.438l-.337-.922-.337.922-.91.341.91.341.337.923.337-.923.91-.34-.91-.342z"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Tripo</title><path d="M12.661 4.752a.04.04 0 00-.013-.055.038.038 0 00-.02-.005H6.496a.19.19 0 01-.165-.092L4.245 1.052a.034.034 0 01.014-.047A.038.038 0 014.275 1c5.492 0 10.733 0 15.721.002 2.089 0 3.699 1.54 4.003 3.598.008.062-.019.092-.08.092h-6.67a.204.204 0 00-.174.1c-1.414 2.41-2.82 4.803-4.218 7.178-.28.472-.92.62-1.373.342-.19-.116-.357-.304-.502-.561a37.917 37.917 0 00-.882-1.489c-.222-.356-.234-.707-.035-1.052a661.77 661.77 0 012.597-4.46v.002z"></path><path d="M10.772 16.986c.57.972 1.935.916 2.489-.028L19 7.164a.127.127 0 01.116-.067h4.23c.017 0 .03.014.028.03 0 .005-.001.01-.003.013-2.605 4.432-5.232 8.906-7.88 13.42-1.283 2.191-4.278 2.517-6.179.947-.308-.254-.665-.727-1.069-1.417-2.525-4.324-5.098-8.71-7.72-13.162-1.063-1.803-.424-3.947 1.19-5.2.053-.041.095-.033.129.023 2.905 4.95 5.882 10.029 8.93 15.236z"></path></svg>

After

Width:  |  Height:  |  Size: 1014 B

@@ -15,6 +15,8 @@ import { ToastContainer } from "../components/toast/toast";
import CreateImage from "../pages/create-image";
import CreateVideo from "../pages/create-video";
import CreateAudio from "../pages/create-audio";
import CreateObject from "../pages/create-object";
import CreateWorld from "../pages/create-world";
import CreateVFX from "../pages/create-vfx";
import PageScene from "../pages/pagescene";
import PageDraw from "../pages/pagedraw";
@@ -155,6 +157,8 @@ export function App() {
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/create-audio" element={<CreateAudio />} />
<Route path="/create-object" element={<CreateObject />} />
<Route path="/create-world" element={<CreateWorld />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/moodboard" element={<MoodboardPage />} />
<Route path="/edit-3d" element={<PageScene />} />
@@ -65,11 +65,15 @@ function CardRecreateButton({
mediaClass,
kind,
}: RecreateSlotContext) {
// Recreate only targets the image/video create pages; 3D generations have no
// recreate flow, so map to a valid class for the hook and render nothing.
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
mediaClass,
mediaClass === "dimensional" ? "image" : mediaClass,
);
if (mediaClass === "dimensional") return null;
if (kind === "failed") {
return (
<button
@@ -81,10 +81,14 @@ function RowRecreateButton({
mediaClass,
kind,
}: RecreateSlotContext) {
// Recreate only targets the image/video create pages; 3D generations have no
// recreate flow, so map to a valid class for the hook and render nothing.
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
mediaClass,
mediaClass === "dimensional" ? "image" : mediaClass,
);
if (mediaClass === "dimensional") return null;
return (
<Tooltip content="Recreate" position="top">
<button
@@ -410,7 +410,7 @@ const ADD_BUTTON_CLASS =
// Upload / Pick-from-library affordance. On desktop it's a hover tooltip; on
// mobile that tooltip auto-opens from the emulated mouseenter fired on
// navigation, so we use a tap-triggered bottom sheet instead.
const AddButton = ({
export const AddButton = ({
onUpload,
onPickFromLibrary,
}: {
@@ -364,11 +364,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
e.g. the audio page), clip it to rounded top corners; mid-stack
(video's start-frame row above) it stays square. */}
{mediaReferenceRow && (
<div
className={twMerge(
!isImageRowVisible && "sm:overflow-hidden sm:rounded-t-2xl",
)}
>
<div className={twMerge(!isImageRowVisible && "sm:rounded-t-2xl")}>
{mediaReferenceRow}
</div>
)}
@@ -547,7 +543,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
className={twMerge(
// right-4 keeps the counter clear of the textarea's resize grip.
"pointer-events-none absolute -bottom-1 right-4 text-[10px] tabular-nums",
isFinite(maxPromptLength) && prompt.length > maxPromptLength
isFinite(maxPromptLength) &&
prompt.length > maxPromptLength
? "text-red-500"
: "text-base-fg/40",
)}
@@ -1,5 +1,5 @@
export { PromptBox } from "./PromptBox";
export { ImagePromptRow } from "./ImagePromptRow";
export { ImagePromptRow, AddButton } from "./ImagePromptRow";
export { MediaReferenceRow } from "./MediaReferenceRow";
export { CharactersModal } from "./CharactersModal";
export { useCharactersStore } from "./characters-store";
@@ -8,6 +8,7 @@ import {
faImage,
faMusic,
faCube,
faGlobe,
faFilm,
faObjectGroup,
faWandMagicSparkles,
@@ -51,14 +52,18 @@ type NavItem = {
const PRIMARY_ITEMS: NavItem[] = [{ label: "Home", href: "/", icon: faHouse }];
// "Edit 3D" entry's href is computed at render time from the
// session-scoped scene-cache store (see useCreateItems below). The other
// entries stay static.
// Generation entries — make something from a prompt.
const CREATE_ITEMS_STATIC: NavItem[] = [
{ label: "Image", href: "/create-image", icon: faImage },
{ label: "Video", href: "/create-video", icon: faVideo },
{ label: "Audio", href: "/create-audio", icon: faMusic },
// Edit Image is hidden from the sidebar for now.
{ label: "3D Object", href: "/create-object", icon: faCube },
{ label: "3D World", href: "/create-world", icon: faGlobe },
];
// "Studio" entries — edit, compose, and refine existing content. "Edit 3D"'s
// href is computed at render time from the scene-cache store (see useStudioItems).
const STUDIO_ITEMS_STATIC: NavItem[] = [
{ label: "Edit Image", href: "/edit-image", icon: faPencil },
{ label: "Edit 3D", href: "/edit-3d", icon: faCube },
{ label: "Edit Video", href: "/video-editor", icon: faFilm, badge: "BETA" },
@@ -79,11 +84,11 @@ const CREATE_ITEMS_STATIC: NavItem[] = [
// scene (if any) so returning to the editor from another sidebar page
// drops them back into the same scene rather than the blank splash.
// sessionStorage scope — closes when the tab closes.
function useCreateItems(): NavItem[] {
function useStudioItems(): NavItem[] {
const lastSceneToken = useSceneCacheStore((s) => s.lastVisitedSceneToken);
return useMemo(
() =>
CREATE_ITEMS_STATIC.map((item) =>
STUDIO_ITEMS_STATIC.map((item) =>
item.href === "/edit-3d" && lastSceneToken
? { ...item, href: `/edit-3d/${lastSceneToken}` }
: item,
@@ -240,7 +245,7 @@ export function AppSidebar() {
const { isMobile, setOpenMobile, state } = useSidebar();
const { user } = useSession();
const showSidebarLogo = state === "expanded" || isMobile;
const createItems = useCreateItems();
const studioItems = useStudioItems();
const hasReferralsFlag = !!user?.maybe_feature_flags?.includes(
USER_FEATURE_FLAGS.REFERRALS,
@@ -280,7 +285,13 @@ export function AppSidebar() {
/>
<NavSection
label="Create"
items={createItems}
items={CREATE_ITEMS_STATIC}
pathname={pathname}
onClick={handleNavClick}
/>
<NavSection
label="Studio"
items={studioItems}
pathname={pathname}
onClick={handleNavClick}
/>
@@ -21,6 +21,7 @@ import {
faCog,
faLifeRing,
faGift,
faImages,
} from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { Button } from "@storyteller/ui-button";
@@ -98,7 +99,7 @@ export function TopBarActions({ className }: { className?: string }) {
return (
<div
className={twMerge(
"flex items-center gap-1 sm:gap-2 shrink-0",
"flex items-center gap-1 sm:gap-1.5 shrink-0",
className,
)}
>
@@ -133,6 +134,14 @@ export function TopBarActions({ className }: { className?: string }) {
<TaskQueue />
<Link
to="/library"
className="hidden sm:flex h-8 items-center gap-1.5 px-3 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-sm font-medium text-white/80 transition-colors"
>
<FontAwesomeIcon icon={faImages} className="text-[11px]" />
My Library
</Link>
<Menu as="div" className="relative ml-1">
<MenuButton className="flex h-8 w-8 rounded-full overflow-hidden focus:outline-none focus:ring-2 focus:ring-primary/40 ring-offset-2 ring-offset-[#121212]">
<span className="sr-only">Open user menu</span>
@@ -7,6 +7,8 @@ const ROUTE_CRUMBS: Record<string, Crumb[]> = {
"/": [{ label: "Home" }],
"/create-image": [{ label: "Create", href: "/" }, { label: "Image" }],
"/create-video": [{ label: "Create", href: "/" }, { label: "Video" }],
"/create-object": [{ label: "Create", href: "/" }, { label: "3D Object" }],
"/create-world": [{ label: "Create", href: "/" }, { label: "3D World" }],
"/background-change": [
{ label: "Create", href: "/" },
{ label: "Background Change" },
@@ -366,7 +366,7 @@ const SidebarContent = React.forwardRef<
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
@@ -427,7 +427,7 @@ const SidebarMenu = React.forwardRef<
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
));
@@ -447,7 +447,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding,background-color,color] duration-200 ease-out hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding,background-color,color] duration-200 ease-out hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -1,6 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { OmniGenApi } from "@storyteller/api";
import type { OmniGenImageRequest, OmniGenVideoRequest } from "@storyteller/api";
import type {
OmniGenImageRequest,
OmniGenVideoRequest,
OmniGenMeshRequest,
OmniGenSplatRequest,
} from "@storyteller/api";
import {
ModelPage,
useSelectedImageModel,
@@ -200,3 +205,138 @@ export function useVideoCostEstimate(params: VideoCostParams): number | null {
return credits;
}
// ── Mesh (3D object) cost estimate hook ──────────────────────────────────
export interface MeshCostParams {
model: string;
referenceImageCount: number;
hasInputMesh: boolean;
meshOutputType?: string | null;
polygonType?: string | null;
faceCount?: number | null;
enablePbr?: boolean | null;
enableTexture?: boolean | null;
textureQuality?: string | null;
geometryQuality?: string | null;
}
export function useMeshCostEstimate(params: MeshCostParams): number | null {
const [credits, setCredits] = useState<number | null>(null);
const abortRef = useRef(0);
useEffect(() => {
if (!params.model) {
setCredits(null);
return;
}
const id = ++abortRef.current;
const body: OmniGenMeshRequest = {
model: params.model,
reference_image_media_tokens:
params.referenceImageCount > 0
? new Array(params.referenceImageCount).fill("placeholder")
: null,
input_mesh_media_token: params.hasInputMesh ? "placeholder" : null,
mesh_output_type: params.meshOutputType ?? null,
polygon_type: params.polygonType ?? null,
face_count: params.faceCount ?? null,
enable_pbr: params.enablePbr ?? null,
enable_texture: params.enableTexture ?? null,
texture_quality: params.textureQuality ?? null,
geometry_quality: params.geometryQuality ?? null,
};
const api = new OmniGenApi();
api.estimateMeshCost(body).then(
(response) => {
if (id !== abortRef.current) return;
if (response.success && response.cost_in_credits != null) {
setCredits(response.cost_in_credits);
} else {
setCredits(null);
}
},
() => {
if (id !== abortRef.current) return;
setCredits(null);
},
);
}, [
params.model,
params.referenceImageCount,
params.hasInputMesh,
params.meshOutputType,
params.polygonType,
params.faceCount,
params.enablePbr,
params.enableTexture,
params.textureQuality,
params.geometryQuality,
]);
return credits;
}
// ── Splat (3D world) cost estimate hook ──────────────────────────────────
export interface SplatCostParams {
model: string;
referenceImageCount: number;
hasReferenceVideo: boolean;
isPanoramic?: boolean | null;
disableRecaption?: boolean | null;
}
export function useSplatCostEstimate(params: SplatCostParams): number | null {
const [credits, setCredits] = useState<number | null>(null);
const abortRef = useRef(0);
useEffect(() => {
if (!params.model) {
setCredits(null);
return;
}
const id = ++abortRef.current;
const body: OmniGenSplatRequest = {
model: params.model,
reference_image_media_tokens:
params.referenceImageCount > 0
? new Array(params.referenceImageCount).fill("placeholder")
: null,
reference_video_media_token: params.hasReferenceVideo
? "placeholder"
: null,
is_panoramic: params.isPanoramic ?? null,
disable_recaption: params.disableRecaption ?? null,
};
const api = new OmniGenApi();
api.estimateSplatCost(body).then(
(response) => {
if (id !== abortRef.current) return;
if (response.success && response.cost_in_credits != null) {
setCredits(response.cost_in_credits);
} else {
setCredits(null);
}
},
() => {
if (id !== abortRef.current) return;
setCredits(null);
},
);
}, [
params.model,
params.referenceImageCount,
params.hasReferenceVideo,
params.isPanoramic,
params.disableRecaption,
]);
return credits;
}
@@ -0,0 +1,314 @@
import { useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faSpinnerThird,
faXmark,
faCube,
faImage,
} from "@fortawesome/pro-solid-svg-icons";
import { MediaUploadApi } from "@storyteller/api";
import { FilterEngineCategories } from "@storyteller/api";
import { UploaderStates } from "@storyteller/common";
import { uploadImage } from "../../../components/prompt-box/upload-image";
import { AddButton, type RefImage } from "../../../components/prompt-box";
// Multi-view side inputs (front/back/left/right) and the mesh-to-mesh input
// file, shown above the prompt box when the selected model supports them. Uses
// the same media upload path (and Upload / Pick-from-library affordance) as the
// primary reference image row.
export type MultiViewSlot = "front" | "back" | "left" | "right";
interface MeshInputsRowProps {
showMultiView: boolean;
showMeshInput: boolean;
frontImage?: RefImage;
backImage?: RefImage;
leftImage?: RefImage;
rightImage?: RefImage;
inputMesh?: RefImage;
onChange: (patch: {
frontImage?: RefImage;
backImage?: RefImage;
leftImage?: RefImage;
rightImage?: RefImage;
inputMesh?: RefImage;
}) => void;
// Opens the library picker targeting a specific multi-view slot.
onPickSlotFromLibrary?: (slot: MultiViewSlot) => void;
}
const MESH_FILE_ACCEPT = ".glb,.gltf,.fbx,.obj";
export function MeshInputsRow({
showMultiView,
showMeshInput,
frontImage,
backImage,
leftImage,
rightImage,
inputMesh,
onChange,
onPickSlotFromLibrary,
}: MeshInputsRowProps) {
if (!showMultiView && !showMeshInput) return null;
// Mesh models are mutually exclusive here (multi-view vs mesh-input), so a
// single title reflects whichever slots are shown.
const title = showMultiView ? "Multi-view" : "Input mesh";
const subtitle = showMultiView
? "Reference angles (optional)"
: "Mesh file to process";
const titleIcon = showMultiView ? faImage : faCube;
return (
// Title on the left, upload slots right-aligned + top-aligned (matches
// ImagePromptRow). Flat bottom on desktop so it seams into the prompt box
// glass stacked below it; fully rounded on mobile (stands alone there).
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2 sm:rounded-t-2xl sm:rounded-b-none">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={titleIcon} className="h-3.5 w-3.5" />
<span className="text-sm font-medium">{title}</span>
</div>
<span className="text-[13px] text-white/60">{subtitle}</span>
</div>
<div className="flex flex-wrap items-end justify-end gap-3">
{showMultiView && (
<>
<ImageSlot
label="Front"
image={frontImage}
onChange={(img) => onChange({ frontImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("front")
: undefined
}
/>
<ImageSlot
label="Back"
image={backImage}
onChange={(img) => onChange({ backImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("back")
: undefined
}
/>
<ImageSlot
label="Left"
image={leftImage}
onChange={(img) => onChange({ leftImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("left")
: undefined
}
/>
<ImageSlot
label="Right"
image={rightImage}
onChange={(img) => onChange({ rightImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("right")
: undefined
}
/>
</>
)}
{showMeshInput && (
<MeshFileSlot
mesh={inputMesh}
onChange={(img) => onChange({ inputMesh: img })}
/>
)}
</div>
</div>
);
}
// ── Slots ──────────────────────────────────────────────────────────────────
const SLOT_CLASS =
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
function SlotShell({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center gap-1">
{children}
<span className="text-[11px] text-white/60">{label}</span>
</div>
);
}
function ImageSlot({
label,
image,
onChange,
onPickFromLibrary,
}: {
label: string;
image?: RefImage;
onChange: (img?: RefImage) => void;
onPickFromLibrary?: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
const reader = new FileReader();
reader.onloadend = async () => {
await uploadImage({
title: `${label.toLowerCase()}-view-${Math.random().toString(36).substring(2, 10)}`,
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
onChange({
id: Math.random().toString(36).substring(7),
url: reader.result as string,
file,
mediaToken: state.data,
});
setUploading(false);
} else if (
state.status === UploaderStates.assetError ||
state.status === UploaderStates.imageCreateError
) {
setUploading(false);
}
},
});
if (inputRef.current) inputRef.current.value = "";
};
reader.readAsDataURL(file);
};
return (
<SlotShell label={label}>
<input
type="file"
ref={inputRef}
className="hidden"
accept="image/*"
onChange={handleUpload}
/>
{image ? (
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
<img
src={image.url}
alt={`${label} view`}
className="h-full w-full object-cover"
/>
<button
onClick={() => onChange(undefined)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
</div>
) : uploading ? (
<div className={SLOT_CLASS}>
<FontAwesomeIcon
icon={faSpinnerThird}
spin
className="h-5 w-5 text-white"
/>
</div>
) : (
<AddButton
onUpload={() => inputRef.current?.click()}
onPickFromLibrary={onPickFromLibrary}
/>
)}
</SlotShell>
);
}
function MeshFileSlot({
mesh,
onChange,
}: {
mesh?: RefImage;
onChange: (img?: RefImage) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const api = new MediaUploadApi();
const response = await api.UploadNewEngineAsset({
file,
fileName: file.name || `input-mesh-${Date.now()}`,
uuid: crypto.randomUUID(),
engine_category: FilterEngineCategories.OBJECT,
maybe_title: "input_mesh",
});
if (response?.success && response.data) {
onChange({
id: Math.random().toString(36).substring(7),
url: "",
file,
mediaToken: response.data,
});
}
} catch {
// ignore — user can retry
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<SlotShell label="Mesh file">
<input
type="file"
ref={inputRef}
className="hidden"
accept={MESH_FILE_ACCEPT}
onChange={handleUpload}
/>
{mesh ? (
<div className="group relative flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/10">
<FontAwesomeIcon icon={faCube} className="h-5 w-5 text-white/80" />
<button
onClick={() => onChange(undefined)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
</div>
) : uploading ? (
<div className={SLOT_CLASS}>
<FontAwesomeIcon
icon={faSpinnerThird}
spin
className="h-5 w-5 text-white"
/>
</div>
) : (
<button
onClick={() => inputRef.current?.click()}
className={SLOT_CLASS}
>
<FontAwesomeIcon icon={faCube} className="text-xl text-white/80" />
</button>
)}
</SlotShell>
);
}
@@ -0,0 +1,175 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCube,
faObjectGroup,
faWandMagicSparkles,
faImage,
faGem,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
// ── Enum label maps (backend value → human label) ──────────────────────────
const MESH_OUTPUT_TYPE_LABELS: Record<string, string> = {
normal: "Standard",
low_poly: "Low Poly",
geometry: "Geometry only",
};
const POLYGON_TYPE_LABELS: Record<string, string> = {
triangle: "Triangle",
quad: "Quad",
};
const MESH_QUALITY_LABELS: Record<string, string> = {
standard: "Standard",
detailed: "Detailed",
};
// Preset target face counts (poly budget). Backend accepts any u64; these are
// sensible presets exposed in the UI.
const FACE_COUNT_PRESETS: number[] = [10000, 30000, 50000, 100000, 200000];
// ── Generic enum picker ────────────────────────────────────────────────────
interface EnumPickerProps {
options: string[];
current?: string;
onSelect: (value: string) => void;
labels: Record<string, string>;
tooltip: string;
panelTitle: string;
icon: IconDefinition;
}
function EnumPicker({
options,
current,
onSelect,
labels,
tooltip,
panelTitle,
icon,
}: EnumPickerProps) {
const labelToValue: Record<string, string> = Object.fromEntries(
options.map((v) => [labels[v] ?? v, v]),
);
const items: PopoverItem[] = options.map((v) => ({
label: labels[v] ?? v,
selected: current === v,
}));
return (
<Tooltip content={tooltip} position="top" className="z-50" closeOnClick>
<PopoverMenu
items={items}
onSelect={(item) => {
const value = labelToValue[item.label];
if (value) onSelect(value);
}}
mode="toggle"
panelTitle={panelTitle}
triggerIcon={<FontAwesomeIcon icon={icon} className="h-3.5 w-3.5" />}
/>
</Tooltip>
);
}
// ── Concrete pickers ───────────────────────────────────────────────────────
export const MeshOutputTypePicker = (props: {
options: string[];
current?: string;
onSelect: (value: string) => void;
}) => (
<EnumPicker
{...props}
labels={MESH_OUTPUT_TYPE_LABELS}
tooltip="Output type"
panelTitle="Output type"
icon={faCube}
/>
);
export const PolygonTypePicker = (props: {
options: string[];
current?: string;
onSelect: (value: string) => void;
}) => (
<EnumPicker
{...props}
labels={POLYGON_TYPE_LABELS}
tooltip="Polygon type"
panelTitle="Polygon type"
icon={faObjectGroup}
/>
);
export const GeometryQualityPicker = (props: {
options: string[];
current?: string;
onSelect: (value: string) => void;
}) => (
<EnumPicker
{...props}
labels={MESH_QUALITY_LABELS}
tooltip="Geometry quality"
panelTitle="Geometry quality"
icon={faWandMagicSparkles}
/>
);
export const TextureQualityPicker = (props: {
options: string[];
current?: string;
onSelect: (value: string) => void;
}) => (
<EnumPicker
{...props}
labels={MESH_QUALITY_LABELS}
tooltip="Texture quality"
panelTitle="Texture quality"
icon={faImage}
/>
);
// Standard two-value quality option lists ("standard" / "detailed").
export const QUALITY_OPTIONS = ["standard", "detailed"];
export const FaceCountPicker = ({
current,
onSelect,
}: {
current?: number;
onSelect: (value: number | undefined) => void;
}) => {
const items: PopoverItem[] = [
{ label: "Auto", selected: current == null },
...FACE_COUNT_PRESETS.map((n) => ({
label: `${n / 1000}k`,
selected: current === n,
})),
];
return (
<Tooltip content="Face count" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={items}
onSelect={(item) => {
if (item.label === "Auto") {
onSelect(undefined);
return;
}
const n = parseFloat(item.label) * 1000;
if (!Number.isNaN(n)) onSelect(n);
}}
mode="toggle"
panelTitle="Face count"
triggerIcon={<FontAwesomeIcon icon={faGem} className="h-3.5 w-3.5" />}
/>
</Tooltip>
);
};
@@ -0,0 +1,169 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { RecreatePayload } from "../../lib/recreate";
import type { RefImage } from "../../components/prompt-box";
// A generated 3D asset (mesh). Shape mirrors the image store's GeneratedImage so
// the shared polling helpers can be reused; `cdn_url` here is the .glb file.
export interface GeneratedAsset {
media_token: string;
cdn_url: string;
maybe_thumbnail_template?: string;
}
export type MeshBatch = {
id: string;
prompt: string;
status: "pending" | "complete" | "failed";
assets: GeneratedAsset[];
createdAt: number;
modelLabel: string;
jobToken?: string;
failureReason?: string;
};
export type MeshUiState = {
selectedModelId: string | null;
prompt: string;
meshOutputType: string | undefined;
polygonType: string | undefined;
faceCount: number | undefined;
enablePbr: boolean | undefined;
enableTexture: boolean | undefined;
textureQuality: string | undefined;
geometryQuality: string | undefined;
};
// Multi-view side inputs + a mesh-to-mesh input file. Not persisted (File
// handles + data URLs aren't serializable / can exceed the storage quota).
export type MeshInputsState = {
frontImage: RefImage | undefined;
backImage: RefImage | undefined;
leftImage: RefImage | undefined;
rightImage: RefImage | undefined;
inputMesh: RefImage | undefined;
};
type CreateObjectState = {
batches: MeshBatch[];
ui: MeshUiState;
referenceImages: RefImage[];
inputs: MeshInputsState;
pendingRecreate: RecreatePayload | null;
setUi: (patch: Partial<MeshUiState>) => void;
setReferenceImages: (images: RefImage[]) => void;
setInputs: (patch: Partial<MeshInputsState>) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
startBatch: (prompt: string, modelLabel: string) => string;
setBatchJobToken: (batchId: string, jobToken: string) => void;
completeBatch: (batchId: string, assets: GeneratedAsset[]) => void;
failBatch: (batchId: string, reason?: string) => void;
dismissBatch: (id: string) => void;
reset: () => void;
};
const DEFAULT_UI: MeshUiState = {
selectedModelId: null,
prompt: "",
meshOutputType: undefined,
polygonType: undefined,
faceCount: undefined,
enablePbr: undefined,
enableTexture: undefined,
textureQuality: undefined,
geometryQuality: undefined,
};
const DEFAULT_INPUTS: MeshInputsState = {
frontImage: undefined,
backImage: undefined,
leftImage: undefined,
rightImage: undefined,
inputMesh: undefined,
};
export const useCreateObjectStore = create<CreateObjectState>()(
persist(
(set, get) => ({
batches: [],
ui: { ...DEFAULT_UI },
referenceImages: [],
inputs: { ...DEFAULT_INPUTS },
pendingRecreate: null,
setUi: (patch) => set((s) => ({ ui: { ...s.ui, ...patch } })),
setReferenceImages: (images) => set({ referenceImages: images }),
setInputs: (patch) => set((s) => ({ inputs: { ...s.inputs, ...patch } })),
setPendingRecreate: (payload) => set({ pendingRecreate: payload }),
consumePendingRecreate: () => {
const payload = get().pendingRecreate;
if (payload) set({ pendingRecreate: null });
return payload;
},
startBatch: (prompt, modelLabel) => {
const id = crypto.randomUUID();
const batch: MeshBatch = {
id,
prompt,
status: "pending",
assets: [],
createdAt: Date.now(),
modelLabel,
};
set((s) => ({ batches: [...s.batches, batch] }));
return id;
},
setBatchJobToken: (batchId, jobToken) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId ? { ...b, jobToken } : b,
),
}));
},
completeBatch: (batchId, assets) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId
? { ...b, status: "complete" as const, assets }
: b,
),
}));
},
failBatch: (batchId, reason) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId
? { ...b, status: "failed" as const, failureReason: reason }
: b,
),
}));
},
dismissBatch: (id) => {
set((s) => ({ batches: s.batches.filter((b) => b.id !== id) }));
},
reset: () => set({ batches: [] }),
}),
{
name: "artcraft-object-prompt",
// Persist only the lightweight settings; reference images / input files
// are excluded (not serializable, can exceed the localStorage quota).
partialize: (state) => ({ ui: state.ui }),
merge: (persisted, current) => {
const persistedUi = (persisted as { ui?: Partial<MeshUiState> } | null)
?.ui;
return { ...current, ui: { ...current.ui, ...(persistedUi ?? {}) } };
},
},
),
);
@@ -0,0 +1,708 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FilterMediaClasses } from "@storyteller/api";
import type { OmniGenMeshModelInfo } from "@storyteller/api";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button, ToggleButton } from "@storyteller/ui-button";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import {
faSparkles,
faGem,
faImage,
} from "@fortawesome/pro-solid-svg-icons";
import {
PromptBox,
ImagePromptRow,
MobilePromptForm,
MobileSelectField,
type RefImage,
} from "../../components/prompt-box";
import {
GenerationGallery,
useGalleryData,
useGenerationJobs,
useAuthCheck,
usePromptHeight,
useLightboxNav,
CreateMediaPageShell,
} from "../../components/generation-gallery";
import { Lightbox } from "../../components/lightbox/lightbox";
import { is3DModelUrl } from "../../components/lightbox/shared";
import { useCreateObjectStore } from "./create-object-store";
import { enqueueMeshGeneration, startPolling } from "./generate-object-api";
import {
MeshOutputTypePicker,
PolygonTypePicker,
GeometryQualityPicker,
TextureQualityPicker,
FaceCountPicker,
QUALITY_OPTIONS,
} from "./components/MeshOptionPickers";
import { MeshInputsRow, type MultiViewSlot } from "./components/MeshInputsRow";
import { useMeshCostEstimate } from "../../lib/cost-estimate-api";
import { resolveModelOption } from "../../lib/resolve-model-setting";
import {
useOmniGenMeshModels,
OMNI_GENERATE_OUTAGE_MESSAGE,
} from "@storyteller/omni-gen";
import {
getCreatorIconPathForModelId,
getModelDescription,
getModelInfo,
} from "@storyteller/model-list";
import { toast } from "../../components/toast/toast";
import { useSignupCta } from "../../components/signup-cta-modal";
import { useInsufficientCredits } from "../../components/insufficient-credits-modal";
// ── Constants ────────────────────────────────────────────────────────────
const DEFAULT_MODEL_ID = "hunyuan_3d_3";
const DIMENSIONAL_FILTER = [FilterMediaClasses.DIMENSIONAL];
let _modelLookup = new Map<string, OmniGenMeshModelInfo>();
function buildModelPopoverItems(
models: OmniGenMeshModelInfo[],
selectedId: string,
): PopoverItem[] {
_modelLookup = new Map(models.map((m) => [m.model, m]));
return models.map((model) => ({
label: model.full_name || model.model,
selected: model.model === selectedId,
description: getModelDescription(model.model, model.extra_info_short),
info: getModelInfo(model.model, model.extra_info) || undefined,
icon: (
<img
src={getCreatorIconPathForModelId(model.model)}
alt={`${model.model} logo`}
className="h-4 w-4 icon-auto-contrast"
/>
),
action: model.model,
}));
}
// ── Component ────────────────────────────────────────────────────────────
export default function CreateObject() {
const { user, authChecked } = useAuthCheck();
const { loggedIn, openSignupCta } = useSignupCta();
const openInsufficientCredits = useInsufficientCredits();
const { promptBoxRef, promptHeight } = usePromptHeight();
const { models: apiModels } = useOmniGenMeshModels();
const ui = useCreateObjectStore((s) => s.ui);
const setUi = useCreateObjectStore((s) => s.setUi);
const selectedModel = useMemo((): OmniGenMeshModelInfo | undefined => {
if (!apiModels.length) return undefined;
if (ui.selectedModelId) {
return (
apiModels.find((m) => m.model === ui.selectedModelId) ??
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
apiModels[0]
);
}
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
}, [apiModels, ui.selectedModelId]);
const prompt = ui.prompt;
const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]);
// Capability flags for the selected model.
const supportsText = !!selectedModel?.text_prompt_supported;
const supportsImage =
!!selectedModel?.image_input_supported ||
!!selectedModel?.sketch_input_supported;
const supportsMultiView = !!selectedModel?.multi_view_supported;
const supportsMeshInput = !!selectedModel?.mesh_input_supported;
const outputTypes = selectedModel?.mesh_output_types ?? [];
const polygonTypes = selectedModel?.polygon_types ?? [];
const supportsGeometryQuality = !!selectedModel?.geometry_quality_supported;
const supportsTextureQuality = !!selectedModel?.texture_quality_supported;
const supportsFaceCount = !!selectedModel?.face_count_supported;
const supportsPbr = !!selectedModel?.pbr_supported;
const supportsTextureToggle = !!selectedModel?.texture_toggle_supported;
// Settings are sticky across model switches, resolved against the current
// model's supported options at read time (see lib/resolve-model-setting).
const meshOutputType = resolveModelOption(ui.meshOutputType, outputTypes, outputTypes[0]);
const polygonType = resolveModelOption(ui.polygonType, polygonTypes, polygonTypes[0]);
const geometryQuality = resolveModelOption(
ui.geometryQuality,
supportsGeometryQuality ? QUALITY_OPTIONS : [],
"standard",
);
const textureQuality = resolveModelOption(
ui.textureQuality,
supportsTextureQuality ? QUALITY_OPTIONS : [],
"standard",
);
// Toggles default on when supported unless the user turned them off.
const enablePbr = supportsPbr ? (ui.enablePbr ?? false) : false;
const enableTexture = supportsTextureToggle ? (ui.enableTexture ?? true) : true;
const [isGenerating, setIsGenerating] = useState(false);
const referenceImages = useCreateObjectStore((s) => s.referenceImages);
const setReferenceImages = useCreateObjectStore((s) => s.setReferenceImages);
const inputs = useCreateObjectStore((s) => s.inputs);
const setInputs = useCreateObjectStore((s) => s.setInputs);
// The library picker is shared: it can target the primary reference image or
// any one of the multi-view slots. `null` = closed.
const [libraryTarget, setLibraryTarget] = useState<
null | "primary" | MultiViewSlot
>(null);
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
useEffect(() => {
if (libraryTarget) setPickerSelectedIds([]);
}, [libraryTarget]);
// Batch store
const startBatch = useCreateObjectStore((s) => s.startBatch);
const setBatchJobToken = useCreateObjectStore((s) => s.setBatchJobToken);
const completeBatch = useCreateObjectStore((s) => s.completeBatch);
const failBatch = useCreateObjectStore((s) => s.failBatch);
const dismissBatch = useCreateObjectStore((s) => s.dismissBatch);
const pollingCleanupsRef = useRef<Map<string, () => void>>(new Map());
// Jobs + gallery (only this page's own — mesh objects, not splats)
const meshModelIds = useMemo(
() => apiModels.map((m) => m.model),
[apiModels],
);
const jobs = useGenerationJobs({ mediaType: "object", enabled: !!user });
const gallery = useGalleryData({
username: user?.username ?? null,
filterMediaClasses: DIMENSIONAL_FILTER,
filterModelIds: meshModelIds,
excludeUploads: true,
});
// The library's dimensional feed can include a 3D model's cover screenshot as
// its own entry (the backend surfaces it as a "dimensional" media file even
// though its asset is a .png). Keep only real 3D-model files so that stray
// screenshot doesn't show up as a separate card.
const modelGalleryItems = useMemo(
() => gallery.items.filter((i) => !!i.fullImage && is3DModelUrl(i.fullImage)),
[gallery.items],
);
// A freshly-completed 3D job has no cover screenshot in its payload (the
// backend renders it a moment later), so its card would be blank. Once the
// library re-fetch returns the same model with its cover, adopt that
// thumbnail so the card stops showing the placeholder.
const galleryById = useMemo(
() => new Map(modelGalleryItems.map((i) => [i.id, i])),
[modelGalleryItems],
);
const enrichedNewlyCompleted = useMemo(
() =>
jobs.newlyCompleted.map((item) => {
if (!item.thumbnail) {
const g = galleryById.get(item.id);
if (g?.thumbnail) return { ...item, thumbnail: g.thumbnail };
}
return item;
}),
[jobs.newlyCompleted, galleryById],
);
const newlyCompletedTokens = useMemo(
() => new Set(enrichedNewlyCompleted.map((i) => i.id)),
[enrichedNewlyCompleted],
);
const flatItems = useMemo(() => {
const filtered = modelGalleryItems.filter(
(i) => !newlyCompletedTokens.has(i.id),
);
return [...enrichedNewlyCompleted, ...filtered];
}, [enrichedNewlyCompleted, modelGalleryItems, newlyCompletedTokens]);
// Re-fetch the library shortly after a 3D generation completes so blank
// just-finished cards can pick up their cover screenshot once it's attached.
const galleryRefreshRef = useRef(gallery.refresh);
galleryRefreshRef.current = gallery.refresh;
useEffect(() => {
if (!jobs.newlyCompleted.some((i) => !i.thumbnail)) return;
const t1 = setTimeout(() => galleryRefreshRef.current(), 4000);
const t2 = setTimeout(() => galleryRefreshRef.current(), 10000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [jobs.newlyCompleted]);
const lightbox = useLightboxNav(flatItems);
const estimatedCredits = useMeshCostEstimate({
model: selectedModel?.model ?? "",
referenceImageCount: referenceImages.length,
hasInputMesh: !!inputs.inputMesh,
meshOutputType: outputTypes.length ? meshOutputType : undefined,
polygonType: polygonTypes.length ? polygonType : undefined,
faceCount: supportsFaceCount ? ui.faceCount : undefined,
enablePbr: supportsPbr ? enablePbr : undefined,
enableTexture: supportsTextureToggle ? enableTexture : undefined,
textureQuality: supportsTextureQuality ? textureQuality : undefined,
geometryQuality: supportsGeometryQuality ? geometryQuality : undefined,
});
const modelItems = useMemo(
() => buildModelPopoverItems(apiModels, selectedModel?.model ?? ""),
[apiModels, selectedModel?.model],
);
const hasContent =
jobs.inProgress.length > 0 ||
jobs.failed.length > 0 ||
jobs.newlyCompleted.length > 0 ||
modelGalleryItems.length > 0 ||
gallery.isInitialLoading;
const hasAnyImage =
referenceImages.length > 0 ||
!!inputs.frontImage ||
!!inputs.backImage ||
!!inputs.leftImage ||
!!inputs.rightImage;
const canGenerate =
!!selectedModel &&
!isGenerating &&
(supportsMeshInput
? !!inputs.inputMesh
: (supportsText && prompt.trim().length > 0) || hasAnyImage);
// ── Effects ──────────────────────────────────────────────────────────────
// Resume polling for pending batches (page revisit).
useEffect(() => {
const cleanups = pollingCleanupsRef.current;
const pendingBatches = useCreateObjectStore
.getState()
.batches.filter((b) => b.status === "pending" && b.jobToken);
for (const batch of pendingBatches) {
if (cleanups.has(batch.id)) continue;
const stop = startPolling(
batch.jobToken!,
(assets) => {
completeBatch(batch.id, assets);
cleanups.delete(batch.id);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failBatch(batch.id, reason);
cleanups.delete(batch.id);
window.dispatchEvent(new Event("task-queue-update"));
},
);
cleanups.set(batch.id, stop);
}
return () => {
cleanups.forEach((stop) => stop());
cleanups.clear();
};
}, [completeBatch, failBatch]);
// ── Handlers ─────────────────────────────────────────────────────────────
const handleModelChange = useCallback(
(item: PopoverItem) => {
const model = item.action ? _modelLookup.get(item.action) : undefined;
if (!model) return;
setUi({ selectedModelId: model.model });
},
[setUi],
);
const handleLibraryImageSelect = useCallback(
(items: GalleryItem[]) => {
const item = items[0];
if (item && libraryTarget) {
const refImage: RefImage = {
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
};
if (libraryTarget === "primary") {
setReferenceImages([refImage]);
} else if (libraryTarget === "front") {
setInputs({ frontImage: refImage });
} else if (libraryTarget === "back") {
setInputs({ backImage: refImage });
} else if (libraryTarget === "left") {
setInputs({ leftImage: refImage });
} else {
setInputs({ rightImage: refImage });
}
}
setLibraryTarget(null);
},
[libraryTarget, setReferenceImages, setInputs],
);
const handleGenerate = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
return;
}
if (!canGenerate || !selectedModel) return;
setIsGenerating(true);
const batchId = startBatch(
prompt || selectedModel.full_name || selectedModel.model,
selectedModel.full_name ?? selectedModel.model,
);
try {
const referenceImageMediaTokens = supportsImage
? referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => typeof t === "string" && t.length > 0)
: undefined;
const result = await enqueueMeshGeneration({
model: selectedModel.model,
prompt: supportsText && prompt.trim() ? prompt.trim() : undefined,
referenceImageMediaTokens: referenceImageMediaTokens?.length
? referenceImageMediaTokens
: undefined,
frontImageMediaToken: supportsMultiView
? inputs.frontImage?.mediaToken
: undefined,
backImageMediaToken: supportsMultiView
? inputs.backImage?.mediaToken
: undefined,
leftImageMediaToken: supportsMultiView
? inputs.leftImage?.mediaToken
: undefined,
rightImageMediaToken: supportsMultiView
? inputs.rightImage?.mediaToken
: undefined,
inputMeshMediaToken: supportsMeshInput
? inputs.inputMesh?.mediaToken
: undefined,
meshOutputType: outputTypes.length ? meshOutputType : undefined,
polygonType: polygonTypes.length ? polygonType : undefined,
faceCount: supportsFaceCount ? ui.faceCount : undefined,
enablePbr: supportsPbr ? enablePbr : undefined,
enableTexture: supportsTextureToggle ? enableTexture : undefined,
textureQuality: supportsTextureQuality ? textureQuality : undefined,
geometryQuality: supportsGeometryQuality ? geometryQuality : undefined,
});
if (!result.success || !result.jobToken) {
if (result.errorCode === 402) {
dismissBatch(batchId);
openInsufficientCredits();
} else {
if (result.errorCode != null && result.errorCode >= 500) {
toast.error(OMNI_GENERATE_OUTAGE_MESSAGE);
}
failBatch(batchId, result.error ?? "Failed to start generation");
}
setIsGenerating(false);
return;
}
setBatchJobToken(batchId, result.jobToken);
window.dispatchEvent(new Event("credits-change"));
window.dispatchEvent(new Event("task-queue-update"));
const stopPolling = startPolling(
result.jobToken,
(assets) => {
completeBatch(batchId, assets);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failBatch(batchId, reason);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
);
pollingCleanupsRef.current.set(batchId, stopPolling);
} catch {
failBatch(batchId, "Network error - please try again");
} finally {
setIsGenerating(false);
}
}, [
loggedIn,
openSignupCta,
openInsufficientCredits,
canGenerate,
selectedModel,
prompt,
supportsText,
supportsImage,
supportsMultiView,
supportsMeshInput,
referenceImages,
inputs,
outputTypes,
meshOutputType,
polygonTypes,
polygonType,
supportsFaceCount,
ui.faceCount,
supportsPbr,
enablePbr,
supportsTextureToggle,
enableTexture,
supportsTextureQuality,
textureQuality,
supportsGeometryQuality,
geometryQuality,
startBatch,
setBatchJobToken,
completeBatch,
failBatch,
dismissBatch,
]);
const placeholder = supportsText
? "Describe the 3D object you want..."
: supportsMeshInput
? "Upload a mesh file below to process"
: "Add a reference image below";
// Only render the extra-inputs row when the model actually supports one of its
// slots, otherwise it renders empty but still flags a "row above" the prompt
// box (flat-top visual glitch).
const meshInputsRow =
supportsMultiView || supportsMeshInput ? (
<MeshInputsRow
showMultiView={supportsMultiView}
showMeshInput={supportsMeshInput}
frontImage={inputs.frontImage}
backImage={inputs.backImage}
leftImage={inputs.leftImage}
rightImage={inputs.rightImage}
inputMesh={inputs.inputMesh}
onChange={setInputs}
onPickSlotFromLibrary={setLibraryTarget}
/>
) : undefined;
// ── Option controls (shared desktop + mobile) ──────────────────────────
const optionControls = (
<>
{outputTypes.length > 0 && (
<MeshOutputTypePicker
options={outputTypes}
current={meshOutputType}
onSelect={(v) => setUi({ meshOutputType: v })}
/>
)}
{polygonTypes.length > 0 && (
<PolygonTypePicker
options={polygonTypes}
current={polygonType}
onSelect={(v) => setUi({ polygonType: v })}
/>
)}
{supportsGeometryQuality && (
<GeometryQualityPicker
options={QUALITY_OPTIONS}
current={geometryQuality}
onSelect={(v) => setUi({ geometryQuality: v })}
/>
)}
{supportsTextureQuality && (
<TextureQualityPicker
options={QUALITY_OPTIONS}
current={textureQuality}
onSelect={(v) => setUi({ textureQuality: v })}
/>
)}
{supportsFaceCount && (
<FaceCountPicker
current={ui.faceCount}
onSelect={(v) => setUi({ faceCount: v })}
/>
)}
{supportsPbr && (
<Tooltip content="PBR materials" position="top" closeOnClick>
<ToggleButton
isActive={enablePbr}
icon={faGem}
activeIcon={faGem}
label={enablePbr ? "PBR" : "PBR off"}
onClick={() => setUi({ enablePbr: !enablePbr })}
/>
</Tooltip>
)}
{supportsTextureToggle && (
<Tooltip content="Generate textures" position="top" closeOnClick>
<ToggleButton
isActive={enableTexture}
icon={faImage}
activeIcon={faImage}
label={enableTexture ? "Texture" : "No texture"}
onClick={() => setUi({ enableTexture: !enableTexture })}
/>
</Tooltip>
)}
</>
);
// ── Mobile form ─────────────────────────────────────────────────────────
const mobileForm = (
<MobilePromptForm
prompt={prompt}
onPromptChange={setPrompt}
onSubmit={handleGenerate}
isSubmitting={isGenerating}
credits={estimatedCredits}
placeholder={placeholder}
autoAdvance={loggedIn && canGenerate}
modelField={
<MobileSelectField
label="Model"
title="Select Model"
items={modelItems}
onSelect={handleModelChange}
/>
}
frames={
supportsImage ? (
<ImagePromptRow
maxImagePromptCount={1}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
onPickFromLibrary={() => setLibraryTarget("primary")}
/>
) : undefined
}
settingsFields={meshInputsRow}
/>
);
// ── Render ────────────────────────────────────────────────────────────
return (
<CreateMediaPageShell
title="3D Object - ArtCraft"
description="Generate 3D objects with ArtCraft"
authChecked={authChecked}
hasContent={hasContent}
emptyStateTitle="Create 3D Object"
emptyStateSubtitle="Turn a prompt or image into a 3D model."
emptyStateCta={
loggedIn ? undefined : (
<Button
variant="primary"
onClick={openSignupCta}
icon={faSparkles}
className="h-12 px-6 text-base font-semibold rounded-full"
>
Sign up to create
</Button>
)
}
bottomOffset={promptHeight + 24}
modelItems={modelItems}
onModelChange={handleModelChange}
promptForm={mobileForm}
gridContent={
<GenerationGallery
inProgressJobs={jobs.inProgress}
failedJobs={jobs.failed}
onDismissFailed={jobs.dismissFailed}
newlyCompletedItems={enrichedNewlyCompleted}
galleryItems={modelGalleryItems}
newlyCompletedTokens={newlyCompletedTokens}
hasMore={gallery.hasMore}
isLoading={gallery.isLoading}
isInitialLoading={gallery.isInitialLoading}
onLoadMore={gallery.loadMore}
onGalleryItemClick={lightbox.handleGalleryItemClick}
/>
}
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-5xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
style={{
animationDelay: "150ms",
left: "var(--ac-sidebar-offset, 0px)",
}}
>
<PromptBox
prompt={prompt}
onPromptChange={setPrompt}
onSubmit={handleGenerate}
isSubmitting={isGenerating}
disabled={!canGenerate}
credits={estimatedCredits}
placeholder={placeholder}
supportsImagePrompts={supportsImage}
maxImagePromptCount={1}
referenceImages={referenceImages}
onReferenceImagesChange={setReferenceImages}
onPickFromLibrary={() => setLibraryTarget("primary")}
mediaReferenceRow={meshInputsRow}
modelSelector={
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={modelItems}
onSelect={handleModelChange}
mode="toggle"
panelTitle="Select Model"
panelClassName="w-[360px]"
richList
triggerIcon={
<img
src={getCreatorIconPathForModelId(selectedModel?.model ?? "")}
alt=""
className="h-4 w-4 icon-auto-contrast"
/>
}
/>
</Tooltip>
}
leftToolbar={optionControls}
/>
</div>
}
modals={
<>
<GalleryModal
mode="select"
isOpen={libraryTarget !== null}
onClose={() => setLibraryTarget(null)}
selectedItemIds={pickerSelectedIds}
onSelectItem={(id) => setPickerSelectedIds([id])}
maxSelections={1}
onUseSelected={handleLibraryImageSelect}
forceFilter="image"
hideFilter
/>
<Lightbox
isOpen={lightbox.lightboxOpen}
onClose={lightbox.closeLightbox}
mediaToken={lightbox.lightboxItem?.id}
cdnUrl={lightbox.lightboxItem?.fullImage}
mediaClass={lightbox.lightboxItem?.mediaClass}
batchImageToken={lightbox.lightboxItem?.batchImageToken}
showBatchCarousel={false}
onNavigatePrev={lightbox.navigatePrev}
onNavigateNext={lightbox.navigateNext}
onDeleted={gallery.removeItem}
/>
</>
}
/>
);
}
@@ -0,0 +1,196 @@
import { JobsApi, MediaFilesApi, OmniGenApi } from "@storyteller/api";
import type { OmniGenMeshRequest } from "@storyteller/api";
import type { GeneratedAsset } from "./create-object-store";
// ── Request params ───────────────────────────────────────────────────────
export interface GenerateMeshParams {
model: string;
prompt?: string;
referenceImageMediaTokens?: string[];
frontImageMediaToken?: string;
backImageMediaToken?: string;
leftImageMediaToken?: string;
rightImageMediaToken?: string;
inputMeshMediaToken?: string;
meshOutputType?: string;
polygonType?: string;
faceCount?: number;
enablePbr?: boolean;
enableTexture?: boolean;
textureQuality?: string;
geometryQuality?: string;
}
// ── Enqueue generation ───────────────────────────────────────────────────
export async function enqueueMeshGeneration(params: GenerateMeshParams): Promise<{
success: boolean;
jobToken?: string;
error?: string;
errorCode?: number;
}> {
const body: OmniGenMeshRequest = {
model: params.model,
idempotency_token: crypto.randomUUID(),
prompt: params.prompt ?? null,
reference_image_media_tokens: params.referenceImageMediaTokens?.length
? params.referenceImageMediaTokens
: null,
front_image_media_token: params.frontImageMediaToken ?? null,
back_image_media_token: params.backImageMediaToken ?? null,
left_image_media_token: params.leftImageMediaToken ?? null,
right_image_media_token: params.rightImageMediaToken ?? null,
input_mesh_media_token: params.inputMeshMediaToken ?? null,
mesh_output_type: params.meshOutputType ?? null,
polygon_type: params.polygonType ?? null,
face_count: params.faceCount ?? null,
enable_pbr: params.enablePbr ?? null,
enable_texture: params.enableTexture ?? null,
texture_quality: params.textureQuality ?? null,
geometry_quality: params.geometryQuality ?? null,
};
try {
const api = new OmniGenApi();
const response = await api.generateMesh(body);
if (response.success && response.inference_job_token) {
return { success: true, jobToken: response.inference_job_token };
}
return { success: false, error: "Generation failed" };
} catch (err: any) {
return {
success: false,
error: err.message ?? "Request failed",
errorCode: parseHttpStatusCode(err),
};
}
}
// Pull the HTTP status out of an ApiManager error (thrown as
// `Error("HTTP error! status: 402")`) so callers can special-case 402.
function parseHttpStatusCode(err: unknown): number | undefined {
const message = err instanceof Error ? err.message : String(err);
const match = /status:\s*(\d+)/.exec(message);
return match ? Number(match[1]) : undefined;
}
// ── Poll for completion ──────────────────────────────────────────────────
async function pollJobResult(
jobToken: string,
): Promise<{ status: "pending" | "complete" | "failed"; assets: GeneratedAsset[]; error?: string }> {
const jobsApi = new JobsApi();
const response = await jobsApi.GetJobByToken({ token: jobToken });
if (!response.success || !response.data) {
return { status: "pending", assets: [] };
}
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 (mediaLinks?.cdn_url) {
const asset: GeneratedAsset = {
media_token: entityToken ?? jobToken,
cdn_url: mediaLinks.cdn_url,
maybe_thumbnail_template: mediaLinks.maybe_thumbnail_template,
};
return { status: "complete", assets: [asset] };
}
// Rare: no media link on the result payload. Confirm the media file exists.
if (entityToken) {
try {
const mediaApi = new MediaFilesApi();
const mediaFile = await mediaApi.GetMediaFileByToken({
mediaFileToken: entityToken,
});
const cdnUrl = (mediaFile.data as any)?.media_links?.cdn_url;
if (cdnUrl) {
return {
status: "complete",
assets: [{ media_token: entityToken, cdn_url: cdnUrl }],
};
}
} catch {
// fall through
}
}
return { status: "complete", assets: [] };
}
if (
statusStr.includes("fail") ||
statusStr.includes("error") ||
statusStr === "dead"
) {
return {
status: "failed",
assets: [],
error:
state.status?.maybe_failure_message ??
state.status?.maybe_extra_status_description ??
"Generation failed",
};
}
return { status: "pending", assets: [] };
}
// ── Polling controller ───────────────────────────────────────────────────
// 3D generations can run a while, so allow a longer window than images.
export function startPolling(
jobToken: string,
onComplete: (assets: GeneratedAsset[]) => void,
onError: (reason: string) => void,
intervalMs = 4000,
maxAttempts = 150,
): () => void {
let attempts = 0;
let stopped = false;
const poll = async () => {
if (stopped) return;
attempts++;
try {
const result = await pollJobResult(jobToken);
if (stopped) return;
if (result.status === "complete") {
onComplete(result.assets);
return;
}
if (result.status === "failed") {
onError(result.error ?? "Generation failed");
return;
}
if (attempts >= maxAttempts) {
onError("Generation timed out");
return;
}
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 @@
export { default } from "./create-object";
@@ -0,0 +1,108 @@
import { useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlus,
faSpinnerThird,
faXmark,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { MediaUploadApi, EIntermediateFile } from "@storyteller/api";
import type { RefVideoAsset } from "../create-world-store";
// A single optional reference video for splat generation. Uploads via the
// generic new-video endpoint and stores the returned media token.
const SLOT_CLASS =
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
export function ReferenceVideoSlot({
video,
onChange,
}: {
video?: RefVideoAsset;
onChange: (video?: RefVideoAsset) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
const previewUrl = URL.createObjectURL(file);
try {
const api = new MediaUploadApi();
const response = await api.UploadNewVideo({
blob: file,
fileName: file.name || `reference-video-${Date.now()}`,
uuid: crypto.randomUUID(),
maybe_title: "splat_reference_video",
is_intermediate_system_file: EIntermediateFile.false,
});
if (response?.success && response.data) {
onChange({
id: Math.random().toString(36).substring(7),
previewUrl,
mediaToken: response.data,
});
} else {
URL.revokeObjectURL(previewUrl);
}
} catch {
URL.revokeObjectURL(previewUrl);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
};
const handleRemove = () => {
if (video?.previewUrl) URL.revokeObjectURL(video.previewUrl);
onChange(undefined);
};
return (
// Title on the left, upload slot right-aligned + top-aligned (matches
// ImagePromptRow). Flat bottom on desktop so it seams into the prompt box.
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2 sm:rounded-t-2xl sm:rounded-b-none">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={faVideo} className="h-3.5 w-3.5" />
<span className="text-sm font-medium">Reference video</span>
</div>
<span className="text-[13px] text-white/60">Guide the world (optional)</span>
</div>
<input
type="file"
ref={inputRef}
className="hidden"
accept="video/*"
onChange={handleUpload}
/>
{video ? (
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
<video
src={video.previewUrl}
muted
preload="metadata"
className="h-full w-full object-cover"
/>
<button
onClick={handleRemove}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
</div>
) : uploading ? (
<div className={SLOT_CLASS}>
<FontAwesomeIcon icon={faSpinnerThird} spin className="h-5 w-5 text-white" />
</div>
) : (
<button onClick={() => inputRef.current?.click()} className={SLOT_CLASS}>
<FontAwesomeIcon icon={faPlus} className="text-xl text-white/80" />
</button>
)}
</div>
);
}
@@ -0,0 +1,146 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { RecreatePayload } from "../../lib/recreate";
import type { RefImage } from "../../components/prompt-box";
// A generated splat (3D world). `cdn_url` here is the .spz file.
export interface GeneratedAsset {
media_token: string;
cdn_url: string;
maybe_thumbnail_template?: string;
}
export type SplatBatch = {
id: string;
prompt: string;
status: "pending" | "complete" | "failed";
assets: GeneratedAsset[];
createdAt: number;
modelLabel: string;
jobToken?: string;
failureReason?: string;
};
export type SplatUiState = {
selectedModelId: string | null;
prompt: string;
isPanoramic: boolean | undefined;
disableRecaption: boolean | undefined;
};
// A stored reference video: media token + a local object-URL for preview. Not
// persisted (File handles / blob URLs aren't serializable).
export type RefVideoAsset = {
id: string;
previewUrl: string;
mediaToken: string;
};
type CreateWorldState = {
batches: SplatBatch[];
ui: SplatUiState;
referenceImages: RefImage[];
referenceVideo: RefVideoAsset | undefined;
pendingRecreate: RecreatePayload | null;
setUi: (patch: Partial<SplatUiState>) => void;
setReferenceImages: (images: RefImage[]) => void;
setReferenceVideo: (video: RefVideoAsset | undefined) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
startBatch: (prompt: string, modelLabel: string) => string;
setBatchJobToken: (batchId: string, jobToken: string) => void;
completeBatch: (batchId: string, assets: GeneratedAsset[]) => void;
failBatch: (batchId: string, reason?: string) => void;
dismissBatch: (id: string) => void;
reset: () => void;
};
const DEFAULT_UI: SplatUiState = {
selectedModelId: null,
prompt: "",
isPanoramic: undefined,
disableRecaption: undefined,
};
export const useCreateWorldStore = create<CreateWorldState>()(
persist(
(set, get) => ({
batches: [],
ui: { ...DEFAULT_UI },
referenceImages: [],
referenceVideo: undefined,
pendingRecreate: null,
setUi: (patch) => set((s) => ({ ui: { ...s.ui, ...patch } })),
setReferenceImages: (images) => set({ referenceImages: images }),
setReferenceVideo: (video) => set({ referenceVideo: video }),
setPendingRecreate: (payload) => set({ pendingRecreate: payload }),
consumePendingRecreate: () => {
const payload = get().pendingRecreate;
if (payload) set({ pendingRecreate: null });
return payload;
},
startBatch: (prompt, modelLabel) => {
const id = crypto.randomUUID();
const batch: SplatBatch = {
id,
prompt,
status: "pending",
assets: [],
createdAt: Date.now(),
modelLabel,
};
set((s) => ({ batches: [...s.batches, batch] }));
return id;
},
setBatchJobToken: (batchId, jobToken) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId ? { ...b, jobToken } : b,
),
}));
},
completeBatch: (batchId, assets) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId
? { ...b, status: "complete" as const, assets }
: b,
),
}));
},
failBatch: (batchId, reason) => {
set((s) => ({
batches: s.batches.map((b) =>
b.id === batchId
? { ...b, status: "failed" as const, failureReason: reason }
: b,
),
}));
},
dismissBatch: (id) => {
set((s) => ({ batches: s.batches.filter((b) => b.id !== id) }));
},
reset: () => set({ batches: [] }),
}),
{
name: "artcraft-world-prompt",
partialize: (state) => ({ ui: state.ui }),
merge: (persisted, current) => {
const persistedUi = (persisted as { ui?: Partial<SplatUiState> } | null)
?.ui;
return { ...current, ui: { ...current.ui, ...(persistedUi ?? {}) } };
},
},
),
);
@@ -0,0 +1,581 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FilterMediaClasses } from "@storyteller/api";
import type { OmniGenSplatModelInfo } from "@storyteller/api";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button, ToggleButton } from "@storyteller/ui-button";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import {
faSparkles,
faPanorama,
faWandMagicSparkles,
} from "@fortawesome/pro-solid-svg-icons";
import {
PromptBox,
ImagePromptRow,
MobilePromptForm,
MobileSelectField,
type RefImage,
} from "../../components/prompt-box";
import {
GenerationGallery,
useGalleryData,
useGenerationJobs,
useAuthCheck,
usePromptHeight,
useLightboxNav,
CreateMediaPageShell,
} from "../../components/generation-gallery";
import { Lightbox } from "../../components/lightbox/lightbox";
import { is3DModelUrl } from "../../components/lightbox/shared";
import { useCreateWorldStore } from "./create-world-store";
import { enqueueSplatGeneration, startPolling } from "./generate-world-api";
import { ReferenceVideoSlot } from "./components/ReferenceVideoSlot";
import { useSplatCostEstimate } from "../../lib/cost-estimate-api";
import {
useOmniGenSplatModels,
OMNI_GENERATE_OUTAGE_MESSAGE,
} from "@storyteller/omni-gen";
import {
getCreatorIconPathForModelId,
getModelDescription,
getModelInfo,
} from "@storyteller/model-list";
import { toast } from "../../components/toast/toast";
import { useSignupCta } from "../../components/signup-cta-modal";
import { useInsufficientCredits } from "../../components/insufficient-credits-modal";
// ── Constants ────────────────────────────────────────────────────────────
const DEFAULT_MODEL_ID = "marble_1p1";
const DIMENSIONAL_FILTER = [FilterMediaClasses.DIMENSIONAL];
let _modelLookup = new Map<string, OmniGenSplatModelInfo>();
function buildModelPopoverItems(
models: OmniGenSplatModelInfo[],
selectedId: string,
): PopoverItem[] {
_modelLookup = new Map(models.map((m) => [m.model, m]));
return models.map((model) => ({
label: model.full_name || model.model,
selected: model.model === selectedId,
description: getModelDescription(model.model, model.extra_info_short),
info: getModelInfo(model.model, model.extra_info) || undefined,
icon: (
<img
src={getCreatorIconPathForModelId(model.model)}
alt={`${model.model} logo`}
className="h-4 w-4 icon-auto-contrast"
/>
),
action: model.model,
}));
}
// ── Component ────────────────────────────────────────────────────────────
export default function CreateWorld() {
const { user, authChecked } = useAuthCheck();
const { loggedIn, openSignupCta } = useSignupCta();
const openInsufficientCredits = useInsufficientCredits();
const { promptBoxRef, promptHeight } = usePromptHeight();
const { models: apiModels } = useOmniGenSplatModels();
const ui = useCreateWorldStore((s) => s.ui);
const setUi = useCreateWorldStore((s) => s.setUi);
const selectedModel = useMemo((): OmniGenSplatModelInfo | undefined => {
if (!apiModels.length) return undefined;
if (ui.selectedModelId) {
return (
apiModels.find((m) => m.model === ui.selectedModelId) ??
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
apiModels[0]
);
}
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
}, [apiModels, ui.selectedModelId]);
const prompt = ui.prompt;
const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]);
const supportsText = !!selectedModel?.text_prompt_supported;
const supportsImage = !!selectedModel?.image_references_supported;
const maxImageRefs = selectedModel?.image_references_max ?? 4;
const supportsVideo = !!selectedModel?.video_reference_supported;
const supportsPanorama = !!selectedModel?.panorama_supported;
const supportsDisableRecaption = !!selectedModel?.disable_recaption_supported;
const isPanoramic = supportsPanorama ? (ui.isPanoramic ?? false) : false;
const disableRecaption = supportsDisableRecaption
? (ui.disableRecaption ?? false)
: false;
const [isGenerating, setIsGenerating] = useState(false);
const referenceImages = useCreateWorldStore((s) => s.referenceImages);
const setReferenceImages = useCreateWorldStore((s) => s.setReferenceImages);
const referenceVideo = useCreateWorldStore((s) => s.referenceVideo);
const setReferenceVideo = useCreateWorldStore((s) => s.setReferenceVideo);
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
useEffect(() => {
if (isImagePickerOpen) setPickerSelectedIds([]);
}, [isImagePickerOpen]);
const imagePickerMax = Math.max(1, maxImageRefs - referenceImages.length);
const handlePickerSelect = useCallback(
(id: string) => {
setPickerSelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= imagePickerMax) {
return imagePickerMax === 1 ? [id] : prev;
}
return [...prev, id];
});
},
[imagePickerMax],
);
// Batch store
const startBatch = useCreateWorldStore((s) => s.startBatch);
const setBatchJobToken = useCreateWorldStore((s) => s.setBatchJobToken);
const completeBatch = useCreateWorldStore((s) => s.completeBatch);
const failBatch = useCreateWorldStore((s) => s.failBatch);
const dismissBatch = useCreateWorldStore((s) => s.dismissBatch);
const pollingCleanupsRef = useRef<Map<string, () => void>>(new Map());
// Jobs + gallery (only splats, not mesh objects)
const splatModelIds = useMemo(
() => apiModels.map((m) => m.model),
[apiModels],
);
const jobs = useGenerationJobs({ mediaType: "splat", enabled: !!user });
const gallery = useGalleryData({
username: user?.username ?? null,
filterMediaClasses: DIMENSIONAL_FILTER,
filterModelIds: splatModelIds,
excludeUploads: true,
});
// A freshly-completed splat job has no cover screenshot in its payload (the
// backend renders it a moment later), so its card would be blank. Once the
// library re-fetch returns the same world with its cover, adopt that
// thumbnail so the card stops showing the placeholder.
// The library's dimensional feed can include a world's cover screenshot as
// its own entry (surfaced as a "dimensional" media file even though its asset
// is a .png). Keep only real 3D/splat files so that stray screenshot doesn't
// show up as a separate card.
const modelGalleryItems = useMemo(
() => gallery.items.filter((i) => !!i.fullImage && is3DModelUrl(i.fullImage)),
[gallery.items],
);
const galleryById = useMemo(
() => new Map(modelGalleryItems.map((i) => [i.id, i])),
[modelGalleryItems],
);
const enrichedNewlyCompleted = useMemo(
() =>
jobs.newlyCompleted.map((item) => {
if (!item.thumbnail) {
const g = galleryById.get(item.id);
if (g?.thumbnail) return { ...item, thumbnail: g.thumbnail };
}
return item;
}),
[jobs.newlyCompleted, galleryById],
);
const newlyCompletedTokens = useMemo(
() => new Set(enrichedNewlyCompleted.map((i) => i.id)),
[enrichedNewlyCompleted],
);
const flatItems = useMemo(() => {
const filtered = modelGalleryItems.filter(
(i) => !newlyCompletedTokens.has(i.id),
);
return [...enrichedNewlyCompleted, ...filtered];
}, [enrichedNewlyCompleted, modelGalleryItems, newlyCompletedTokens]);
// Re-fetch the library shortly after a generation completes so blank
// just-finished cards can pick up their cover screenshot once it's attached.
const galleryRefreshRef = useRef(gallery.refresh);
galleryRefreshRef.current = gallery.refresh;
useEffect(() => {
if (!jobs.newlyCompleted.some((i) => !i.thumbnail)) return;
const t1 = setTimeout(() => galleryRefreshRef.current(), 4000);
const t2 = setTimeout(() => galleryRefreshRef.current(), 10000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [jobs.newlyCompleted]);
const lightbox = useLightboxNav(flatItems);
const estimatedCredits = useSplatCostEstimate({
model: selectedModel?.model ?? "",
referenceImageCount: referenceImages.length,
hasReferenceVideo: !!referenceVideo,
isPanoramic: supportsPanorama ? isPanoramic : undefined,
disableRecaption: supportsDisableRecaption ? disableRecaption : undefined,
});
const modelItems = useMemo(
() => buildModelPopoverItems(apiModels, selectedModel?.model ?? ""),
[apiModels, selectedModel?.model],
);
const hasContent =
jobs.inProgress.length > 0 ||
jobs.failed.length > 0 ||
jobs.newlyCompleted.length > 0 ||
modelGalleryItems.length > 0 ||
gallery.isInitialLoading;
const canGenerate =
!!selectedModel &&
!isGenerating &&
(prompt.trim().length > 0 || referenceImages.length > 0 || !!referenceVideo);
// ── Effects ──────────────────────────────────────────────────────────────
useEffect(() => {
const cleanups = pollingCleanupsRef.current;
const pendingBatches = useCreateWorldStore
.getState()
.batches.filter((b) => b.status === "pending" && b.jobToken);
for (const batch of pendingBatches) {
if (cleanups.has(batch.id)) continue;
const stop = startPolling(
batch.jobToken!,
(assets) => {
completeBatch(batch.id, assets);
cleanups.delete(batch.id);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failBatch(batch.id, reason);
cleanups.delete(batch.id);
window.dispatchEvent(new Event("task-queue-update"));
},
);
cleanups.set(batch.id, stop);
}
return () => {
cleanups.forEach((stop) => stop());
cleanups.clear();
};
}, [completeBatch, failBatch]);
// ── Handlers ─────────────────────────────────────────────────────────────
const handleModelChange = useCallback(
(item: PopoverItem) => {
const model = item.action ? _modelLookup.get(item.action) : undefined;
if (!model) return;
setUi({ selectedModelId: model.model });
},
[setUi],
);
const handleLibraryImageSelect = useCallback(
(items: GalleryItem[]) => {
const availableSlots = Math.max(0, maxImageRefs - referenceImages.length);
const newImages: RefImage[] = items
.slice(0, availableSlots)
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
fullUrl: item.fullImage || undefined,
file: new File([], "library-image"),
mediaToken: item.id,
}));
setReferenceImages([...referenceImages, ...newImages]);
setIsImagePickerOpen(false);
},
[maxImageRefs, referenceImages, setReferenceImages],
);
const handleGenerate = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
return;
}
if (!canGenerate || !selectedModel) return;
setIsGenerating(true);
const batchId = startBatch(
prompt || selectedModel.full_name || selectedModel.model,
selectedModel.full_name ?? selectedModel.model,
);
try {
const referenceImageMediaTokens = supportsImage
? referenceImages
.map((img) => img.mediaToken)
.filter((t): t is string => typeof t === "string" && t.length > 0)
: undefined;
const result = await enqueueSplatGeneration({
model: selectedModel.model,
prompt: supportsText && prompt.trim() ? prompt.trim() : undefined,
referenceImageMediaTokens: referenceImageMediaTokens?.length
? referenceImageMediaTokens
: undefined,
referenceVideoMediaToken: supportsVideo
? referenceVideo?.mediaToken
: undefined,
isPanoramic: supportsPanorama ? isPanoramic : undefined,
disableRecaption: supportsDisableRecaption ? disableRecaption : undefined,
});
if (!result.success || !result.jobToken) {
if (result.errorCode === 402) {
dismissBatch(batchId);
openInsufficientCredits();
} else {
if (result.errorCode != null && result.errorCode >= 500) {
toast.error(OMNI_GENERATE_OUTAGE_MESSAGE);
}
failBatch(batchId, result.error ?? "Failed to start generation");
}
setIsGenerating(false);
return;
}
setBatchJobToken(batchId, result.jobToken);
window.dispatchEvent(new Event("credits-change"));
window.dispatchEvent(new Event("task-queue-update"));
const stopPolling = startPolling(
result.jobToken,
(assets) => {
completeBatch(batchId, assets);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failBatch(batchId, reason);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
);
pollingCleanupsRef.current.set(batchId, stopPolling);
} catch {
failBatch(batchId, "Network error - please try again");
} finally {
setIsGenerating(false);
}
}, [
loggedIn,
openSignupCta,
openInsufficientCredits,
canGenerate,
selectedModel,
prompt,
supportsText,
supportsImage,
supportsVideo,
supportsPanorama,
supportsDisableRecaption,
referenceImages,
referenceVideo,
isPanoramic,
disableRecaption,
startBatch,
setBatchJobToken,
completeBatch,
failBatch,
dismissBatch,
]);
// ── Option controls (shared desktop + mobile) ──────────────────────────
const optionControls = (
<>
{supportsPanorama && (
<Tooltip content="360° panorama input" position="top" closeOnClick>
<ToggleButton
isActive={isPanoramic}
icon={faPanorama}
activeIcon={faPanorama}
label={isPanoramic ? "Panorama" : "Panorama off"}
onClick={() => setUi({ isPanoramic: !isPanoramic })}
/>
</Tooltip>
)}
{supportsDisableRecaption && (
<Tooltip content="Use prompt exactly (skip recaption)" position="top" closeOnClick>
<ToggleButton
isActive={disableRecaption}
icon={faWandMagicSparkles}
activeIcon={faWandMagicSparkles}
label={disableRecaption ? "No recaption" : "Recaption"}
onClick={() => setUi({ disableRecaption: !disableRecaption })}
/>
</Tooltip>
)}
</>
);
const videoRow = supportsVideo ? (
<ReferenceVideoSlot video={referenceVideo} onChange={setReferenceVideo} />
) : undefined;
// ── Mobile form ─────────────────────────────────────────────────────────
const mobileForm = (
<MobilePromptForm
prompt={prompt}
onPromptChange={setPrompt}
onSubmit={handleGenerate}
isSubmitting={isGenerating}
credits={estimatedCredits}
placeholder="Describe the 3D world you want..."
autoAdvance={loggedIn && canGenerate}
modelField={
<MobileSelectField
label="Model"
title="Select Model"
items={modelItems}
onSelect={handleModelChange}
/>
}
frames={
supportsImage ? (
<ImagePromptRow
maxImagePromptCount={maxImageRefs}
referenceImages={referenceImages}
setReferenceImages={setReferenceImages}
onPickFromLibrary={() => setIsImagePickerOpen(true)}
/>
) : undefined
}
settingsFields={videoRow}
/>
);
// ── Render ────────────────────────────────────────────────────────────
return (
<CreateMediaPageShell
title="3D World - ArtCraft"
description="Generate explorable 3D worlds with ArtCraft"
authChecked={authChecked}
hasContent={hasContent}
emptyStateTitle="Create 3D World"
emptyStateSubtitle="Turn a prompt or image into an explorable world."
emptyStateCta={
loggedIn ? undefined : (
<Button
variant="primary"
onClick={openSignupCta}
icon={faSparkles}
className="h-12 px-6 text-base font-semibold rounded-full"
>
Sign up to create
</Button>
)
}
bottomOffset={promptHeight + 24}
modelItems={modelItems}
onModelChange={handleModelChange}
promptForm={mobileForm}
gridContent={
<GenerationGallery
inProgressJobs={jobs.inProgress}
failedJobs={jobs.failed}
onDismissFailed={jobs.dismissFailed}
newlyCompletedItems={enrichedNewlyCompleted}
galleryItems={modelGalleryItems}
newlyCompletedTokens={newlyCompletedTokens}
hasMore={gallery.hasMore}
isLoading={gallery.isLoading}
isInitialLoading={gallery.isInitialLoading}
onLoadMore={gallery.loadMore}
onGalleryItemClick={lightbox.handleGalleryItemClick}
/>
}
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 right-0 z-30 mx-auto max-w-5xl px-2 sm:px-4 transition-[left] duration-200 ease-linear"
style={{
animationDelay: "150ms",
left: "var(--ac-sidebar-offset, 0px)",
}}
>
<PromptBox
prompt={prompt}
onPromptChange={setPrompt}
onSubmit={handleGenerate}
isSubmitting={isGenerating}
disabled={!canGenerate}
credits={estimatedCredits}
placeholder="Describe the 3D world you want..."
supportsImagePrompts={supportsImage}
maxImagePromptCount={maxImageRefs}
referenceImages={referenceImages}
onReferenceImagesChange={setReferenceImages}
onPickFromLibrary={() => setIsImagePickerOpen(true)}
mediaReferenceRow={videoRow}
modelSelector={
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={modelItems}
onSelect={handleModelChange}
mode="toggle"
panelTitle="Select Model"
panelClassName="w-[360px]"
richList
triggerIcon={
<img
src={getCreatorIconPathForModelId(selectedModel?.model ?? "")}
alt=""
className="h-4 w-4 icon-auto-contrast"
/>
}
/>
</Tooltip>
}
leftToolbar={optionControls}
/>
</div>
}
modals={
<>
<GalleryModal
mode="select"
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
forceFilter="image"
hideFilter
/>
<Lightbox
isOpen={lightbox.lightboxOpen}
onClose={lightbox.closeLightbox}
mediaToken={lightbox.lightboxItem?.id}
cdnUrl={lightbox.lightboxItem?.fullImage}
mediaClass={lightbox.lightboxItem?.mediaClass}
batchImageToken={lightbox.lightboxItem?.batchImageToken}
showBatchCarousel={false}
onNavigatePrev={lightbox.navigatePrev}
onNavigateNext={lightbox.navigateNext}
onDeleted={gallery.removeItem}
/>
</>
}
/>
);
}
@@ -0,0 +1,181 @@
import { JobsApi, MediaFilesApi, OmniGenApi } from "@storyteller/api";
import type { OmniGenSplatRequest } from "@storyteller/api";
import type { GeneratedAsset } from "./create-world-store";
// ── Request params ───────────────────────────────────────────────────────
export interface GenerateSplatParams {
model: string;
prompt?: string;
referenceImageMediaTokens?: string[];
referenceVideoMediaToken?: string;
isPanoramic?: boolean;
disableRecaption?: boolean;
}
// ── Enqueue generation ───────────────────────────────────────────────────
export async function enqueueSplatGeneration(
params: GenerateSplatParams,
): Promise<{
success: boolean;
jobToken?: string;
error?: string;
errorCode?: number;
}> {
const body: OmniGenSplatRequest = {
model: params.model,
idempotency_token: crypto.randomUUID(),
prompt: params.prompt ?? null,
reference_image_media_tokens: params.referenceImageMediaTokens?.length
? params.referenceImageMediaTokens
: null,
reference_video_media_token: params.referenceVideoMediaToken ?? null,
is_panoramic: params.isPanoramic ?? null,
disable_recaption: params.disableRecaption ?? null,
};
try {
const api = new OmniGenApi();
const response = await api.generateSplat(body);
if (response.success && response.inference_job_token) {
return { success: true, jobToken: response.inference_job_token };
}
return { success: false, error: "Generation failed" };
} catch (err: any) {
return {
success: false,
error: err.message ?? "Request failed",
errorCode: parseHttpStatusCode(err),
};
}
}
function parseHttpStatusCode(err: unknown): number | undefined {
const message = err instanceof Error ? err.message : String(err);
const match = /status:\s*(\d+)/.exec(message);
return match ? Number(match[1]) : undefined;
}
// ── Poll for completion ──────────────────────────────────────────────────
async function pollJobResult(
jobToken: string,
): Promise<{ status: "pending" | "complete" | "failed"; assets: GeneratedAsset[]; error?: string }> {
const jobsApi = new JobsApi();
const response = await jobsApi.GetJobByToken({ token: jobToken });
if (!response.success || !response.data) {
return { status: "pending", assets: [] };
}
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 (mediaLinks?.cdn_url) {
return {
status: "complete",
assets: [
{
media_token: entityToken ?? jobToken,
cdn_url: mediaLinks.cdn_url,
maybe_thumbnail_template: mediaLinks.maybe_thumbnail_template,
},
],
};
}
if (entityToken) {
try {
const mediaApi = new MediaFilesApi();
const mediaFile = await mediaApi.GetMediaFileByToken({
mediaFileToken: entityToken,
});
const cdnUrl = (mediaFile.data as any)?.media_links?.cdn_url;
if (cdnUrl) {
return {
status: "complete",
assets: [{ media_token: entityToken, cdn_url: cdnUrl }],
};
}
} catch {
// fall through
}
}
return { status: "complete", assets: [] };
}
if (
statusStr.includes("fail") ||
statusStr.includes("error") ||
statusStr === "dead"
) {
return {
status: "failed",
assets: [],
error:
state.status?.maybe_failure_message ??
state.status?.maybe_extra_status_description ??
"Generation failed",
};
}
return { status: "pending", assets: [] };
}
// ── Polling controller ───────────────────────────────────────────────────
// Splat / world generation is slow, so allow a generous window.
export function startPolling(
jobToken: string,
onComplete: (assets: GeneratedAsset[]) => void,
onError: (reason: string) => void,
intervalMs = 5000,
maxAttempts = 180,
): () => void {
let attempts = 0;
let stopped = false;
const poll = async () => {
if (stopped) return;
attempts++;
try {
const result = await pollJobResult(jobToken);
if (stopped) return;
if (result.status === "complete") {
onComplete(result.assets);
return;
}
if (result.status === "failed") {
onError(result.error ?? "Generation failed");
return;
}
if (attempts >= maxAttempts) {
onError("Generation timed out");
return;
}
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 @@
export { default } from "./create-world";
@@ -7,6 +7,7 @@ import {
faMusic,
faWandMagicSparkles,
faCube,
faGlobe,
faFilm,
faObjectGroup,
faArrowRight,
@@ -57,6 +58,24 @@ const APPS: AppCard[] = [
iconBg: "bg-pink-500/25 border-pink-400/30",
iconColor: "text-pink-300",
},
{
label: "3D Object",
description: "Turn a prompt or image into a 3D model.",
href: "/create-object",
icon: faCube,
accent: "from-cyan-500/20 to-cyan-500/0",
iconBg: "bg-cyan-500/25 border-cyan-400/30",
iconColor: "text-cyan-300",
},
{
label: "3D World",
description: "Turn a prompt or image into an explorable world.",
href: "/create-world",
icon: faGlobe,
accent: "from-teal-500/20 to-teal-500/0",
iconBg: "bg-teal-500/25 border-teal-400/30",
iconColor: "text-teal-300",
},
{
label: "Edit 3D",
description: "Compose 3D scenes and render with AI cameras.",
@@ -23,7 +23,7 @@ import {
FOLDER_DROP_EVENT,
type GalleryItem,
} from "@storyteller/ui-gallery-modal";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
import { PLACEHOLDER_IMAGES, is3DModelUrl } from "@storyteller/common";
import {
showActionReminder,
isActionReminderOpen,
@@ -285,7 +285,14 @@ export default function Library() {
if (response.success && response.data) {
const newItems = response.data
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
// Drop 3D-model cover screenshots the backend surfaces as
// "dimensional" items whose asset is actually a .png.
!(
item.media_class === "dimensional" &&
!is3DModelUrl(item.media_links?.cdn_url)
),
)
.map(mapRawToGalleryItem);
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
@@ -46,6 +46,7 @@ module.exports = {
green: { ...colors.green, DEFAULT: "#308752" },
emerald: colors.emerald,
teal: colors.teal,
cyan: colors.cyan,
yellow: colors.yellow,
pink: colors.pink,
purple: colors.purple,
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 134.36 135.45"><defs><style>.cls-1{fill:#fff;}</style></defs><g id="Layer_1-2"><path class="cls-1" d="M43.48,35.1c6.03-.05,11.65-1.42,16.41-3.77,6.79-4.43,10.34-10.91,8.32-18.93C66.63,6.14,60.82.22,52.99,0,29.78-.61,7.27,27.85,6.37,52.67c9.08-11.39,22.16-17.45,37.11-17.58ZM102.56,75.86c7.21-12.31,8.6-28.65.63-41.29-7.37-5.64-18.16-6.36-27.03-5.54,7.39,3.49,13.52,7.4,17.96,13.73,7.07,9.52,9.38,20.88,8.44,33.1ZM21.31,55.53c5.16-8.31,12.73-13.82,21.44-17.82-15.34-.32-28.83,7.33-36.66,19.8.07,9.84,3.05,18.72,9.08,26.22-1.43-10.35.39-19.89,6.14-28.2ZM131.1,101.8c3.16-6.46,3.46-13.31,3.19-20.31-2.23-17.6-10.95-33.91-26.59-44.3,4.91,12.97,4.23,26.92-2.61,39.15-2.97,5.31-5.03,11.12-5.75,17.05.09,8.65,4.58,15.39,12.26,17.49s15.7-1.35,19.5-9.1ZM78.37,64.17c-1.95-7.81-6.43-13.46-13-15.54,3.95,4.17,6.12,8.23,5.71,13.26-.34,4.22-3.26,7.44-6.85,10.65,5.45-1.51,10.08-4.25,14.13-8.37ZM49.88,93.89c9.44,2.63,19.59-.35,25.02-8.75,3.48-5.39,4.62-11.78,3.81-18.3-8.54,7.51-18.31,9.82-28.97,7.96l.14,19.09ZM70.77,129.26c-14.67-2.16-26.48-10.66-33.83-23.32-2.91-5-6.94-9.21-11.48-12.36-7.78-4.01-16.06-3.09-21.46,3.2-4.88,5.69-5.47,14.34-.74,20.89,13.45,18.63,45.84,22.83,67.52,11.59ZM93.06,106.37c-8.34,5.81-16.34,9.2-25.74,8.51-10.41.17-19.39-3.81-27.88-9.62,7.62,13.32,20.74,21.39,35.62,21.96,8.15-4.92,14.6-11.66,18-20.84Z"/><path d="M131.1,101.8c-3.79,7.75-12.08,11.13-19.5,9.1s-12.17-8.84-12.26-17.49c.73-5.93,2.78-11.74,5.75-17.05,6.84-12.24,7.51-26.18,2.61-39.15,15.63,10.39,24.36,26.7,26.59,44.3.27,7-.03,13.85-3.19,20.31Z"/><path d="M70.77,129.26c-21.68,11.24-54.06,7.04-67.52-11.59-4.73-6.55-4.14-15.2.74-20.89,5.4-6.29,13.68-7.21,21.46-3.2,4.55,3.15,8.58,7.36,11.48,12.36,7.36,12.65,19.16,21.15,33.83,23.32Z"/><path d="M43.48,35.1c-14.95.13-28.03,6.19-37.11,17.58C7.27,27.85,29.78-.61,52.99,0c7.83.21,13.64,6.13,15.22,12.39,2.03,8.01-1.53,14.5-8.32,18.93-4.77,2.35-10.38,3.72-16.41,3.77Z"/><path d="M49.88,93.89l-.14-19.09c10.65,1.86,20.43-.45,28.97-7.96.81,6.52-.32,12.91-3.81,18.3-5.43,8.4-15.58,11.38-25.02,8.75Z"/><path d="M93.06,106.37c-3.4,9.18-9.85,15.92-18,20.84-14.88-.56-28-8.63-35.62-21.96,8.5,5.82,17.47,9.79,27.88,9.62,9.4.69,17.4-2.7,25.74-8.51Z"/><path d="M102.56,75.86c.94-12.21-1.37-23.57-8.44-33.1-4.44-6.34-10.57-10.25-17.96-13.73,8.87-.82,19.67-.11,27.03,5.54,7.97,12.64,6.57,28.98-.63,41.29Z"/><path d="M21.31,55.53c-5.75,8.32-7.56,17.85-6.14,28.2-6.03-7.5-9.02-16.38-9.08-26.22,7.83-12.48,21.31-20.13,36.66-19.8-8.71,4-16.28,9.51-21.44,17.82Z"/><path d="M78.37,64.17c-4.06,4.12-8.68,6.86-14.13,8.37,3.58-3.21,6.51-6.43,6.85-10.65.41-5.03-1.76-9.09-5.71-13.26,6.56,2.08,11.05,7.73,13,15.54Z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Meshy</title><path d="M3.214.358l.59 1.606 1.597.595-1.596.594-.59 1.607-.591-1.607-1.596-.594 1.596-.595.59-1.606zM16.3 1.691a8.04 8.04 0 012.178.641c6.94 2.96 6.273 11.893.308 11.306l-.008-.007c.09.247.177.51.25.779.803 2.395-2.534 3.633-4.663 3.785l.138-.078c2.107-1.3 2.17-6.033-.175-5.56-.855.303-1.641 1.093-2 2.503a6.199 6.199 0 00-.093.436 25.87 25.87 0 00-.102.614c-.147.94-.32 2.038-.821 2.66-.578.041-1.153.043-1.711.001.737-.577 1.098-1.385 1.377-2.584-3.215.395-6.084-.384-6.257-4.094.17.809.509 1.431.972 1.896.267.133.61.252 1.043.345.911.196 1.928.07 2.646-.116.36.237.648.518.823.843.23-.027.464-.061.7-.102a4.523 4.523 0 00-.01-.197 3.115 3.115 0 01-.06-.209c-.108-.395-.278-1.023-.953-1.476-.391-.261-.76-.609-.696-1.075.064-.459.478-.731.93-.84 2.84-.683 4.568-2.988 5.45-5.648C14.988 6.75 14.129 8 13.032 8.947c-2.062 1.781-5.857 1.971-7.106.487.364.217.776.359 1.219.431-.299-.122-.54-.291-.665-.52 3.221.894 6.802-2.252 8.148-6.784-2.963-.128-6.335 1.44-8.25 4.01 2.084-3.387 6.04-5.44 9.916-4.88h.007zm2.57 5.476c-.357 1.21-.224 2.564.444 3.459.058.078.121.154.188.225l-.89.809c-1.327-.571-1.541-2.22-.99-3.741a5.04 5.04 0 011.32-1.997 3.822 3.822 0 011.195-.747l.003.004a3.511 3.511 0 00-.559.574c-.322.415-.56.902-.711 1.414z"></path><path d="M20.352 14.028c6.788 3.227-.077 9.51-10.095 8.441-.36-.033-.72-.08-1.077-.142 3.05-.146 5.386-.591 7.887-2.766-5.111 3.23-14.152 2.117-12.294-1.535-.236 3.143 10.482 2.832 15.062-1.501-.73 2.168-1.945 3.664-3.423 4.62 3.978-1.476 6.496-4.16 3.94-7.117zM7.742 8.427s.236.142.573-.039c.546-.292.673-2.054.673-3.083 0-.278-.342-.283-.342-.283l-.029.003c-.398.042-.704.264-.682.495.079.862-.071 1.94-.27 2.64-.028.1.003.192.077.267zM11.395 6.972c-.338.181-.573.039-.573.039a.263.263 0 01-.078-.267c.2-.7.349-1.778.27-2.64-.021-.231.285-.453.683-.495.01 0 .019-.002.028-.003 0 0 .343.005.343.283 0 1.029-.128 2.79-.673 3.083zM1.056 5.438l-.337-.922-.337.922-.91.341.91.341.337.923.337-.923.91-.34-.91-.342z"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Tripo</title><path d="M12.661 4.752a.04.04 0 00-.013-.055.038.038 0 00-.02-.005H6.496a.19.19 0 01-.165-.092L4.245 1.052a.034.034 0 01.014-.047A.038.038 0 014.275 1c5.492 0 10.733 0 15.721.002 2.089 0 3.699 1.54 4.003 3.598.008.062-.019.092-.08.092h-6.67a.204.204 0 00-.174.1c-1.414 2.41-2.82 4.803-4.218 7.178-.28.472-.92.62-1.373.342-.19-.116-.357-.304-.502-.561a37.917 37.917 0 00-.882-1.489c-.222-.356-.234-.707-.035-1.052a661.77 661.77 0 012.597-4.46v.002z"></path><path d="M10.772 16.986c.57.972 1.935.916 2.489-.028L19 7.164a.127.127 0 01.116-.067h4.23c.017 0 .03.014.028.03 0 .005-.001.01-.003.013-2.605 4.432-5.232 8.906-7.88 13.42-1.283 2.191-4.278 2.517-6.179.947-.308-.254-.665-.727-1.069-1.417-2.525-4.324-5.098-8.71-7.72-13.162-1.063-1.803-.424-3.947 1.19-5.2.053-.041.095-.033.129.023 2.905 4.95 5.882 10.029 8.93 15.236z"></path></svg>

After

Width:  |  Height:  |  Size: 1014 B

+187
View File
@@ -304,6 +304,137 @@ export interface OmniGenProviderEntry {
models: OmniGenProviderModelEntry[];
}
// ── Mesh (3D object) request / response types ────────────────────────────
// Shared body for POST /v1/omni_gen/cost/mesh and /v1/omni_gen/generate/mesh.
export interface OmniGenMeshRequest {
// Required despite being nullable (backend rejects when absent).
model: string;
idempotency_token?: string | null;
prompt?: string | null;
// Primary/front input image, or the sketch image for sketch-to-3D.
reference_image_media_tokens?: string[] | null;
// Multi-view input (one image per side).
front_image_media_token?: string | null;
back_image_media_token?: string | null;
left_image_media_token?: string | null;
right_image_media_token?: string | null;
// Input mesh file for mesh-to-mesh models (part splitting, retopology).
input_mesh_media_token?: string | null;
// "normal" | "low_poly" | "geometry"
mesh_output_type?: string | null;
// "triangle" | "quad"
polygon_type?: string | null;
face_count?: number | null;
enable_pbr?: boolean | null;
enable_texture?: boolean | null;
// "standard" | "detailed"
texture_quality?: string | null;
geometry_quality?: string | null;
}
export interface OmniGenMeshCostResponse {
success: boolean;
cost_in_credits?: number | null;
cost_in_usd_cents?: number | null;
has_watermark: boolean;
is_free: boolean;
is_rate_limited: boolean;
is_unlimited: boolean;
failures_are_refunded?: boolean | null;
}
export interface OmniGenMeshGenerateResponse {
success: boolean;
inference_job_token: string;
all_job_tokens: string[];
}
export interface OmniGenMeshModelInfo {
model: string;
// ModelCreator enum as a snake_case string, e.g. "tencent".
model_creator?: string | null;
full_name?: string | null;
extra_info_short?: string | null;
extra_info?: string | null;
text_prompt_supported?: boolean | null;
image_input_supported?: boolean | null;
sketch_input_supported?: boolean | null;
multi_view_supported?: boolean | null;
mesh_input_supported?: boolean | null;
// CommonMeshOutputType values: "normal" | "low_poly" | "geometry".
mesh_output_types?: string[] | null;
// CommonPolygonType values: "triangle" | "quad".
polygon_types?: string[] | null;
face_count_supported?: boolean | null;
pbr_supported?: boolean | null;
texture_toggle_supported?: boolean | null;
texture_quality_supported?: boolean | null;
geometry_quality_supported?: boolean | null;
is_disabled?: boolean | null;
}
export interface OmniGenMeshModelsResponse {
success: boolean;
models: OmniGenMeshModelInfo[];
providers: OmniGenProviderEntry[];
}
// ── Splat (3D world) request / response types ────────────────────────────
// Shared body for POST /v1/omni_gen/cost/splat and /v1/omni_gen/generate/splat.
export interface OmniGenSplatRequest {
// Required despite being nullable (backend rejects when absent).
model: string;
idempotency_token?: string | null;
prompt?: string | null;
// Multi-view input of the same world.
reference_image_media_tokens?: string[] | null;
reference_video_media_token?: string | null;
// Whether the single reference image is a 360-degree panorama.
is_panoramic?: boolean | null;
disable_recaption?: boolean | null;
}
export interface OmniGenSplatCostResponse {
success: boolean;
cost_in_credits?: number | null;
cost_in_usd_cents?: number | null;
has_watermark: boolean;
is_free: boolean;
is_rate_limited: boolean;
is_unlimited: boolean;
failures_are_refunded?: boolean | null;
}
export interface OmniGenSplatGenerateResponse {
success: boolean;
inference_job_token: string;
all_job_tokens: string[];
}
export interface OmniGenSplatModelInfo {
model: string;
// ModelCreator enum as a snake_case string, e.g. "world_labs".
model_creator?: string | null;
full_name?: string | null;
extra_info_short?: string | null;
extra_info?: string | null;
text_prompt_supported?: boolean | null;
image_references_supported?: boolean | null;
image_references_max?: number | null;
video_reference_supported?: boolean | null;
panorama_supported?: boolean | null;
disable_recaption_supported?: boolean | null;
is_disabled?: boolean | null;
}
export interface OmniGenSplatModelsResponse {
success: boolean;
models: OmniGenSplatModelInfo[];
providers: OmniGenProviderEntry[];
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** Strip keys whose value is null or undefined so the server only sees
@@ -347,6 +478,26 @@ export class OmniGenApi extends ApiManager {
});
}
public async getMeshModels(
provider?: string,
): Promise<OmniGenMeshModelsResponse> {
const query = provider ? { provider } : undefined;
return this.get<OmniGenMeshModelsResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/models/mesh`,
query,
});
}
public async getSplatModels(
provider?: string,
): Promise<OmniGenSplatModelsResponse> {
const query = provider ? { provider } : undefined;
return this.get<OmniGenSplatModelsResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/models/splat`,
query,
});
}
// ── Cost estimates ───────────────────────────────────────────────────
public async estimateImageCost(
@@ -376,6 +527,24 @@ export class OmniGenApi extends ApiManager {
});
}
public async estimateMeshCost(
body: OmniGenMeshRequest,
): Promise<OmniGenMeshCostResponse> {
return this.post<Record<string, unknown>, OmniGenMeshCostResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/cost/mesh`,
body: stripNulls(body),
});
}
public async estimateSplatCost(
body: OmniGenSplatRequest,
): Promise<OmniGenSplatCostResponse> {
return this.post<Record<string, unknown>, OmniGenSplatCostResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/cost/splat`,
body: stripNulls(body),
});
}
// ── Generation ───────────────────────────────────────────────────────
public async generateImage(
@@ -404,4 +573,22 @@ export class OmniGenApi extends ApiManager {
body: stripNulls(body),
});
}
public async generateMesh(
body: OmniGenMeshRequest,
): Promise<OmniGenMeshGenerateResponse> {
return this.post<Record<string, unknown>, OmniGenMeshGenerateResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/mesh`,
body: stripNulls(body),
});
}
public async generateSplat(
body: OmniGenSplatRequest,
): Promise<OmniGenSplatGenerateResponse> {
return this.post<Record<string, unknown>, OmniGenSplatGenerateResponse>({
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/splat`,
body: stripNulls(body),
});
}
}
@@ -143,3 +143,16 @@ export function getPlaceholderForMediaClass(mediaClass?: string): string {
return PLACEHOLDER_IMAGES.DEFAULT;
}
}
// File extensions for actual 3D-model assets (meshes + gaussian splats).
export const MODEL_3D_EXTENSIONS = [".glb", ".gltf", ".fbx", ".spz"];
/** Whether a URL points at a real 3D-model asset (vs. e.g. a cover screenshot).
* Used to keep 3D-model cover images — which the backend can surface as
* "dimensional" media files whose asset is actually a .png — out of the 3D
* feeds and library. */
export function is3DModelUrl(url: string | null | undefined): boolean {
if (!url) return false;
const lower = url.toLowerCase();
return MODEL_3D_EXTENSIONS.some((ext) => lower.includes(ext));
}
@@ -41,6 +41,7 @@ import {
getMediaThumbnail,
THUMBNAIL_SIZES,
PLACEHOLDER_IMAGES,
is3DModelUrl,
} from "@storyteller/common";
import {
galleryModalDraggingUnder,
@@ -883,7 +884,14 @@ export const GalleryModal = React.memo(
setItemsLoadError(null);
const newItems = response.data
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
// Drop 3D-model cover screenshots the backend surfaces as
// "dimensional" items whose asset is actually a .png.
!(
item.media_class === "dimensional" &&
!is3DModelUrl(item.media_links?.cdn_url)
),
)
.map(mapApiItem);
@@ -1,7 +1,11 @@
// Canonical data shapes for the merged generation feed (in-progress / failed /
// completed). Hosts (webapp, desktop) map their own job sources into these.
export type GenerationMediaClass = "image" | "video" | "audio";
export type GenerationMediaClass =
| "image"
| "video"
| "audio"
| "dimensional";
/** Plural noun for batch captions, e.g. "Generating 4 images". */
export function batchNoun(mediaClass: GenerationMediaClass): string {
@@ -10,6 +14,8 @@ export function batchNoun(mediaClass: GenerationMediaClass): string {
return "videos";
case "audio":
return "audio clips";
case "dimensional":
return "3D models";
default:
return "images";
}
@@ -27,12 +27,32 @@ const getLabel = (item: any) => {
// ── Hook ───────────────────────────────────────────────────────────────────
// Pull a cover image / screenshot URL off a dimensional (3D) library item. The
// item's own cdn_url is the .glb/.spz asset, so 3D cards render the separate
// cover image the backend attaches instead. Mirrors the gallery-modal logic.
function get3DCoverThumbnail(item: any): string | null {
const cover = item?.cover_image;
if (!cover) return null;
return (
cover.maybe_links?.maybe_thumbnail_template?.replace("{WIDTH}", "512") ??
cover.maybe_links?.cdn_url ??
cover.maybe_cover_image_public_bucket_url ??
null
);
}
export function useGalleryData(options: {
username: string | null;
filterMediaClasses: FilterMediaClasses[];
excludeUploads?: boolean;
// When set, dimensional (3D) items are kept only if their model belongs to
// this list. The library API filters by media class ("dimensional") but can't
// tell mesh objects from splat worlds apart, so the two 3D create pages pass
// their own model ids to keep each feed showing only its own product.
filterModelIds?: string[];
}) {
const { username, filterMediaClasses, excludeUploads } = options;
const { username, filterMediaClasses, excludeUploads, filterModelIds } =
options;
const [items, setItems] = useState<GalleryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -44,15 +64,16 @@ export function useGalleryData(options: {
const api = useMemo(() => new GalleryModalApi(), []);
const mapApiItem = useCallback((item: any): GalleryItem => {
// 3D and audio media have no image thumbnail — their cards render a
// placeholder icon / waveform player instead.
const hasImageThumbnail =
item.media_class !== "dimensional" && item.media_class !== "audio";
const thumbnail = hasImageThumbnail
? getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
})
: null;
// 3D uses the cover image / screenshot; audio has no thumbnail (its card
// renders a waveform player); everything else uses the media thumbnail.
const thumbnail =
item.media_class === "audio"
? null
: item.media_class === "dimensional"
? get3DCoverThumbnail(item)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
@@ -85,11 +106,25 @@ export function useGalleryData(options: {
});
if (response.success && response.data) {
const modelIdSet =
filterModelIds && filterModelIds.length
? new Set(filterModelIds)
: null;
const newItems = response.data
.filter(
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
!(excludeUploads && item.origin_category === "upload"),
!(excludeUploads && item.origin_category === "upload") &&
// Split 3D history by model so the object and world pages don't
// show each other's generations. Non-dimensional items and items
// without a known model are left untouched.
!(
modelIdSet &&
item.media_class === "dimensional" &&
item.maybe_model_type &&
!modelIdSet.has(item.maybe_model_type)
),
)
.map(mapApiItem);
@@ -112,7 +147,15 @@ export function useGalleryData(options: {
setIsInitialLoading(false);
isLoadingRef.current = false;
},
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
[
username,
filterMediaClasses,
pageIndex,
api,
mapApiItem,
excludeUploads,
filterModelIds,
],
);
// Initial load + filter change. When logged out (no username), clear the
@@ -129,7 +172,8 @@ export function useGalleryData(options: {
setHasMore(true);
setIsInitialLoading(true);
loadItems(true);
}, [username, JSON.stringify(filterMediaClasses)]); // eslint-disable-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [username, JSON.stringify(filterMediaClasses), JSON.stringify(filterModelIds)]);
const loadMore = useCallback(() => {
if (hasMore && !isLoadingRef.current) {
@@ -50,6 +50,7 @@ const DEFAULT_DURATION_MS: Record<GenerationMediaClass, number> = {
image: 30000,
video: 900000,
audio: 120000,
dimensional: 120000,
};
// Cache per-task durations
@@ -57,11 +58,24 @@ const taskDurationCache = new Map<string, number>();
// ── Helpers ────────────────────────────────────────────────────────────────
// The backend `inference_category` strings for 3D are "object_generation"
// (mesh) and "splat_generation" (gaussian splats). Both render as the
// "dimensional" media class, but stay distinct here so the two 3D create pages
// can each filter to only their own generations.
export type JobMediaFilter =
| "image"
| "video"
| "audio"
| "object"
| "splat";
function getJobMediaType(
job: Job,
): "image" | "video" | "audio" | "other" {
): JobMediaFilter | "other" {
const cat = job.request.inference_category?.toLowerCase() ?? "";
if (cat.includes("video")) return "video";
if (cat.includes("object")) return "object";
if (cat.includes("splat") || cat.includes("gaussian")) return "splat";
if (cat.includes("image")) return "image";
if (cat.includes("audio")) return "audio";
return "other";
@@ -69,9 +83,25 @@ function getJobMediaType(
function getJobMediaClass(job: Job): GenerationMediaClass {
const inferred = getJobMediaType(job);
if (inferred === "object" || inferred === "splat") return "dimensional";
return inferred === "other" ? "image" : inferred;
}
// Pull a cover image / screenshot URL off a 3D result (job result or a batch
// media file). The raw cdn_url is the .glb/.spz asset itself, so 3D cards use
// the separate cover image the backend attaches; falls back to null (the card
// then shows its 3D placeholder).
function get3DCoverThumbnail(source: any): string | null {
const cover = source?.cover_image;
if (!cover) return null;
return (
cover.maybe_links?.maybe_thumbnail_template?.replace("{WIDTH}", "512") ??
cover.maybe_links?.cdn_url ??
cover.maybe_cover_image_public_bucket_url ??
null
);
}
function getModelLabel(job: Job, promptsMap?: Map<string, Prompts>): string {
const promptToken = job.request.maybe_prompt_token;
const cachedPrompt = promptToken
@@ -204,13 +234,16 @@ function jobToGalleryItem(
if (!result?.entity_token) return null;
const mediaClass = getJobMediaClass(job);
// Audio has no image thumbnail — its card renders a waveform player.
// Audio has no image thumbnail — its card renders a waveform player. 3D uses
// the result's cover image / screenshot (the cdn_url is the mesh/splat file).
const thumbnail =
mediaClass === "audio"
? null
: getMediaThumbnail(result.media_links, mediaClass, {
size: THUMBNAIL_SIZES.LARGE,
});
: mediaClass === "dimensional"
? get3DCoverThumbnail(result)
: getMediaThumbnail(result.media_links, mediaClass, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: result.entity_token,
@@ -249,9 +282,11 @@ async function expandBatchItems(
const thumbnail =
item.mediaClass === "audio"
? null
: getMediaThumbnail(file.media_links, item.mediaClass, {
size: THUMBNAIL_SIZES.LARGE,
}) || cdnUrl;
: item.mediaClass === "dimensional"
? get3DCoverThumbnail(file)
: getMediaThumbnail(file.media_links, item.mediaClass, {
size: THUMBNAIL_SIZES.LARGE,
}) || cdnUrl;
return {
id: file.token,
label: item.label,
@@ -273,7 +308,7 @@ async function expandBatchItems(
// ── Hook ───────────────────────────────────────────────────────────────────
export function useGenerationJobs(options: {
mediaType: GenerationMediaClass;
mediaType: JobMediaFilter;
enabled?: boolean;
}) {
const { mediaType, enabled = true } = options;
@@ -159,10 +159,16 @@ export const Tooltip = ({
let current = triggerRef.current.parentElement;
while (current && current !== document.body) {
const cs = getComputedStyle(current);
// backdrop-filter also establishes a containing block for position:fixed
// in Chromium/WebKit (our glass surfaces use backdrop-blur), so a fixed
// tooltip inside one would otherwise be positioned/clipped incorrectly.
const backdropFilter =
cs.backdropFilter || (cs as unknown as Record<string, string>)["webkitBackdropFilter"];
if (
cs.transform !== "none" ||
cs.willChange === "transform" ||
(cs.filter && cs.filter !== "none")
(cs.filter && cs.filter !== "none") ||
(backdropFilter && backdropFilter !== "none")
) {
const r = current.getBoundingClientRect();
return { x: r.left, y: r.top };
@@ -25,6 +25,19 @@ export const MODEL_ID_PREFIX_CREATORS: Array<[string, ModelCreator]> = [
// Beeble SwitchX (background change) has no provider icon — use the ArtCraft
// mark since it's surfaced as an ArtCraft feature.
["switch_x", ModelCreator.ArtCraft],
// 3D mesh / splat models (not in ALL_MODELS_LIST).
["hunyuan", ModelCreator.Tencent],
["marble", ModelCreator.WorldLabs],
];
// Direct model-id-prefix → services icon filename, for 3D brands that don't
// have a ModelCreator enum value yet. Drop the matching .svg into
// public/images/services/ (webapp) and resources/images/services/ (desktop).
const MODEL_ID_PREFIX_ICON_FILES: Array<[string, string]> = [
["tripo", "tripo.svg"],
["meshy", "meshy.svg"],
// Rodin is Hyper3D's model.
["rodin", "hyper3d.svg"],
];
/**
@@ -36,8 +49,12 @@ export const getCreatorIconPathForModelId = (modelId: string): string => {
(m) => m.id === modelId || m.tauriId === modelId,
);
if (model) return getCreatorIconPath(model.creator);
const base = getServicesBasePath();
for (const [prefix, file] of MODEL_ID_PREFIX_ICON_FILES) {
if (modelId.startsWith(prefix)) return `${base}/${file}`;
}
for (const [prefix, creator] of MODEL_ID_PREFIX_CREATORS) {
if (modelId.startsWith(prefix)) return getCreatorIconPath(creator);
}
return `${getServicesBasePath()}/generic.svg`;
return `${base}/generic.svg`;
};
@@ -4,6 +4,8 @@ import type {
OmniGenAudioModelDetails,
OmniGenImageModelInfo,
OmniGenVideoModelInfo,
OmniGenMeshModelInfo,
OmniGenSplatModelInfo,
} from "@storyteller/api";
// Shown when the OmniGen service is unavailable (e.g. returns 5xx). Separate
@@ -44,6 +46,16 @@ let audioModelsFetching = false;
let audioModelsError: string | null = null;
let audioModelsListeners: Array<() => void> = [];
let meshModelsCache: OmniGenMeshModelInfo[] | null = null;
let meshModelsFetching = false;
let meshModelsError: string | null = null;
let meshModelsListeners: Array<() => void> = [];
let splatModelsCache: OmniGenSplatModelInfo[] | null = null;
let splatModelsFetching = false;
let splatModelsError: string | null = null;
let splatModelsListeners: Array<() => void> = [];
function notifyImageListeners() {
imageModelsListeners.forEach((cb) => cb());
imageModelsListeners = [];
@@ -59,6 +71,16 @@ function notifyAudioListeners() {
audioModelsListeners = [];
}
function notifyMeshListeners() {
meshModelsListeners.forEach((cb) => cb());
meshModelsListeners = [];
}
function notifySplatListeners() {
splatModelsListeners.forEach((cb) => cb());
splatModelsListeners = [];
}
function fetchImageModelsOnce() {
if (imageModelsCache || imageModelsFetching) return;
imageModelsFetching = true;
@@ -140,6 +162,60 @@ function fetchAudioModelsOnce() {
);
}
function fetchMeshModelsOnce() {
if (meshModelsCache || meshModelsFetching) return;
meshModelsFetching = true;
meshModelsError = null;
const api = new OmniGenApi();
api.getMeshModels().then(
(res) => {
meshModelsFetching = false;
if (res.success && res.models?.length) {
meshModelsCache = res.models.filter((m) => m.is_disabled !== true);
} else {
meshModelsError = "No mesh models returned from API";
console.warn("[OmniGen] Mesh models response:", res);
errorNotifier(OMNI_MODELS_OUTAGE_MESSAGE);
}
notifyMeshListeners();
},
(err) => {
meshModelsFetching = false;
meshModelsError = err?.message ?? "Failed to fetch mesh models";
console.error("[OmniGen] Failed to fetch mesh models:", err);
errorNotifier(OMNI_MODELS_OUTAGE_MESSAGE);
notifyMeshListeners();
},
);
}
function fetchSplatModelsOnce() {
if (splatModelsCache || splatModelsFetching) return;
splatModelsFetching = true;
splatModelsError = null;
const api = new OmniGenApi();
api.getSplatModels().then(
(res) => {
splatModelsFetching = false;
if (res.success && res.models?.length) {
splatModelsCache = res.models.filter((m) => m.is_disabled !== true);
} else {
splatModelsError = "No splat models returned from API";
console.warn("[OmniGen] Splat models response:", res);
errorNotifier(OMNI_MODELS_OUTAGE_MESSAGE);
}
notifySplatListeners();
},
(err) => {
splatModelsFetching = false;
splatModelsError = err?.message ?? "Failed to fetch splat models";
console.error("[OmniGen] Failed to fetch splat models:", err);
errorNotifier(OMNI_MODELS_OUTAGE_MESSAGE);
notifySplatListeners();
},
);
}
// ── Hooks ────────────────────────────────────────────────────────────────
export function useOmniGenImageModels(): {
@@ -267,3 +343,85 @@ export function useOmniGenAudioModels(): {
return { models, isLoading, error };
}
export function useOmniGenMeshModels(): {
models: OmniGenMeshModelInfo[];
isLoading: boolean;
error: string | null;
} {
const [models, setModels] = useState<OmniGenMeshModelInfo[]>(
meshModelsCache ?? [],
);
const [isLoading, setIsLoading] = useState(!meshModelsCache);
const [error, setError] = useState<string | null>(meshModelsError);
useEffect(() => {
if (meshModelsCache) {
setModels(meshModelsCache);
setIsLoading(false);
setError(null);
return;
}
const onReady = () => {
setIsLoading(false);
if (meshModelsCache) {
setModels(meshModelsCache);
setError(null);
} else {
setError(meshModelsError);
}
};
meshModelsListeners.push(onReady);
fetchMeshModelsOnce();
return () => {
meshModelsListeners = meshModelsListeners.filter((cb) => cb !== onReady);
};
}, []);
return { models, isLoading, error };
}
export function useOmniGenSplatModels(): {
models: OmniGenSplatModelInfo[];
isLoading: boolean;
error: string | null;
} {
const [models, setModels] = useState<OmniGenSplatModelInfo[]>(
splatModelsCache ?? [],
);
const [isLoading, setIsLoading] = useState(!splatModelsCache);
const [error, setError] = useState<string | null>(splatModelsError);
useEffect(() => {
if (splatModelsCache) {
setModels(splatModelsCache);
setIsLoading(false);
setError(null);
return;
}
const onReady = () => {
setIsLoading(false);
if (splatModelsCache) {
setModels(splatModelsCache);
setError(null);
} else {
setError(splatModelsError);
}
};
splatModelsListeners.push(onReady);
fetchSplatModelsOnce();
return () => {
splatModelsListeners = splatModelsListeners.filter(
(cb) => cb !== onReady,
);
};
}, []);
return { models, isLoading, error };
}