mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 08:57:54 +00:00
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:
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 />} />
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+5
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
+175
@@ -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";
|
||||
+108
@@ -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 |
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user