diff --git a/frontend/apps/artcraft-webapp/src/app/app.tsx b/frontend/apps/artcraft-webapp/src/app/app.tsx
index a8cd520333..10d18c1d13 100644
--- a/frontend/apps/artcraft-webapp/src/app/app.tsx
+++ b/frontend/apps/artcraft-webapp/src/app/app.tsx
@@ -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() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/frontend/apps/artcraft-webapp/src/components/generation-gallery/GenerationGalleryGrid.tsx b/frontend/apps/artcraft-webapp/src/components/generation-gallery/GenerationGalleryGrid.tsx
index 6ad03427af..73647cbb05 100644
--- a/frontend/apps/artcraft-webapp/src/components/generation-gallery/GenerationGalleryGrid.tsx
+++ b/frontend/apps/artcraft-webapp/src/components/generation-gallery/GenerationGalleryGrid.tsx
@@ -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 (
+ )
+ }
+ bottomOffset={promptHeight + 24}
+ modelItems={modelItems}
+ onModelChange={handleModelChange}
+ promptForm={mobileForm}
+ gridContent={
+
+ }
+ promptBox={
+
+
setIsImagePickerOpen(true)}
+ mediaReferenceRow={videoRow}
+ modelSelector={
+
+
+ }
+ />
+
+ }
+ leftToolbar={optionControls}
+ />
+
+ }
+ modals={
+ <>
+ setIsImagePickerOpen(false)}
+ selectedItemIds={pickerSelectedIds}
+ onSelectItem={handlePickerSelect}
+ maxSelections={imagePickerMax}
+ onUseSelected={handleLibraryImageSelect}
+ forceFilter="image"
+ hideFilter
+ />
+
+ >
+ }
+ />
+ );
+}
diff --git a/frontend/apps/artcraft-webapp/src/pages/create-world/generate-world-api.ts b/frontend/apps/artcraft-webapp/src/pages/create-world/generate-world-api.ts
new file mode 100644
index 0000000000..8d7740d0de
--- /dev/null
+++ b/frontend/apps/artcraft-webapp/src/pages/create-world/generate-world-api.ts
@@ -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 | 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;
+ };
+}
diff --git a/frontend/apps/artcraft-webapp/src/pages/create-world/index.ts b/frontend/apps/artcraft-webapp/src/pages/create-world/index.ts
new file mode 100644
index 0000000000..2b64241c01
--- /dev/null
+++ b/frontend/apps/artcraft-webapp/src/pages/create-world/index.ts
@@ -0,0 +1 @@
+export { default } from "./create-world";
diff --git a/frontend/libs/api/src/lib/OmniGenApi.ts b/frontend/libs/api/src/lib/OmniGenApi.ts
index 65fd8e3010..0910cebe3e 100644
--- a/frontend/libs/api/src/lib/OmniGenApi.ts
+++ b/frontend/libs/api/src/lib/OmniGenApi.ts
@@ -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 {
+ const query = provider ? { provider } : undefined;
+ return this.get({
+ endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/models/mesh`,
+ query,
+ });
+ }
+
+ public async getSplatModels(
+ provider?: string,
+ ): Promise {
+ const query = provider ? { provider } : undefined;
+ return this.get({
+ 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 {
+ return this.post, OmniGenMeshCostResponse>({
+ endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/cost/mesh`,
+ body: stripNulls(body),
+ });
+ }
+
+ public async estimateSplatCost(
+ body: OmniGenSplatRequest,
+ ): Promise {
+ return this.post, 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 {
+ return this.post, OmniGenMeshGenerateResponse>({
+ endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/mesh`,
+ body: stripNulls(body),
+ });
+ }
+
+ public async generateSplat(
+ body: OmniGenSplatRequest,
+ ): Promise {
+ return this.post, OmniGenSplatGenerateResponse>({
+ endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/splat`,
+ body: stripNulls(body),
+ });
+ }
}
diff --git a/frontend/libs/components/generation-list/src/lib/types.ts b/frontend/libs/components/generation-list/src/lib/types.ts
index 1999e5fed5..0ce99603ce 100644
--- a/frontend/libs/components/generation-list/src/lib/types.ts
+++ b/frontend/libs/components/generation-list/src/lib/types.ts
@@ -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";
}
diff --git a/frontend/libs/components/generation-list/src/lib/useGalleryData.ts b/frontend/libs/components/generation-list/src/lib/useGalleryData.ts
index 1f47c94041..a4ca4996b4 100644
--- a/frontend/libs/components/generation-list/src/lib/useGalleryData.ts
+++ b/frontend/libs/components/generation-list/src/lib/useGalleryData.ts
@@ -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([]);
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) {
diff --git a/frontend/libs/components/generation-list/src/lib/useGenerationJobs.ts b/frontend/libs/components/generation-list/src/lib/useGenerationJobs.ts
index 99e12eb6ec..3f3419c5fc 100644
--- a/frontend/libs/components/generation-list/src/lib/useGenerationJobs.ts
+++ b/frontend/libs/components/generation-list/src/lib/useGenerationJobs.ts
@@ -50,6 +50,7 @@ const DEFAULT_DURATION_MS: Record = {
image: 30000,
video: 900000,
audio: 120000,
+ dimensional: 120000,
};
// Cache per-task durations
@@ -57,11 +58,24 @@ const taskDurationCache = new Map();
// ── 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 {
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;
diff --git a/frontend/libs/omni-gen/src/lib/omni-gen-hooks.ts b/frontend/libs/omni-gen/src/lib/omni-gen-hooks.ts
index 41fc0db977..bad68e5180 100644
--- a/frontend/libs/omni-gen/src/lib/omni-gen-hooks.ts
+++ b/frontend/libs/omni-gen/src/lib/omni-gen-hooks.ts
@@ -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(
+ meshModelsCache ?? [],
+ );
+ const [isLoading, setIsLoading] = useState(!meshModelsCache);
+ const [error, setError] = useState(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(
+ splatModelsCache ?? [],
+ );
+ const [isLoading, setIsLoading] = useState(!splatModelsCache);
+ const [error, setError] = useState(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 };
+}