diff --git a/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts b/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts index 51da62b40b..758092366a 100644 --- a/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts +++ b/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts @@ -12,6 +12,7 @@ import { mergeCollageUrls, } from "@storyteller/ui-gallery-modal"; import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common"; +import { is3DMediaClass } from "@storyteller/ui-generation-list"; import { toast } from "../../components/toast/toast"; // ── Bulk selection store ──────────────────────────────────────────────────── @@ -141,7 +142,10 @@ const getLabel = (item: any): string => { case "video": return "Video Generation"; case "dimensional": + case "mesh": return "3D Mesh"; + case "splat": + return "3D World"; default: return "Generation"; } @@ -149,12 +153,11 @@ const getLabel = (item: any): string => { /** Map a raw user-media list row (origin_category shape) → GalleryItem (root library). */ export function mapRawToGalleryItem(item: any): GalleryItem { - const thumbnail = - item.media_class === "dimensional" - ? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null) - : getMediaThumbnail(item.media_links, item.media_class, { - size: THUMBNAIL_SIZES.LARGE, - }); + const thumbnail = is3DMediaClass(item.media_class) + ? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null) + : getMediaThumbnail(item.media_links, item.media_class, { + size: THUMBNAIL_SIZES.LARGE, + }); return { id: item.token, label: getLabel(item), @@ -172,12 +175,11 @@ export function mapRawToGalleryItem(item: any): GalleryItem { function mapFolderListItemToGalleryItem( item: FolderMediaFileListItem, ): GalleryItem { - const thumbnail = - item.media_class === "dimensional" - ? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null) - : getMediaThumbnail(item.media_links, item.media_class, { - size: THUMBNAIL_SIZES.LARGE, - }); + const thumbnail = is3DMediaClass(item.media_class) + ? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null) + : getMediaThumbnail(item.media_links, item.media_class, { + size: THUMBNAIL_SIZES.LARGE, + }); return { id: item.token, label: getLabel(item), diff --git a/frontend/apps/artcraft-webapp/src/pages/library/library.tsx b/frontend/apps/artcraft-webapp/src/pages/library/library.tsx index 54f1f76ad2..1c6223157a 100644 --- a/frontend/apps/artcraft-webapp/src/pages/library/library.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/library/library.tsx @@ -8,6 +8,7 @@ import { LoadingSpinner } from "@storyteller/ui-loading-spinner"; import { UsersApi, GalleryModalApi, + MediaFilesApi, FilterMediaClasses, FilterMediaType, } from "@storyteller/api"; @@ -24,6 +25,7 @@ import { type GalleryItem, } from "@storyteller/ui-gallery-modal"; import { PLACEHOLDER_IMAGES, is3DModelUrl } from "@storyteller/common"; +import { is3DMediaClass } from "@storyteller/ui-generation-list"; import { showActionReminder, isActionReminderOpen, @@ -41,6 +43,7 @@ import { faFolderPlus, faFolder, faFolderOpen, + faGlobe, faPlus, faXmark, faStar, @@ -62,6 +65,7 @@ const FILTERS = [ { id: "video", label: "Videos", icon: faVideo, route: "/library/videos" }, { id: "audio", label: "Audio", icon: faMusic, route: "/library/audio" }, { id: "meshes", label: "Meshes", icon: faCube, route: "/library/meshes" }, + { id: "splats", label: "Splats", icon: faGlobe, route: "/library/splats" }, ]; const ROUTE_TO_FILTER: Record = { @@ -69,8 +73,11 @@ const ROUTE_TO_FILTER: Record = { videos: "video", audio: "audio", meshes: "meshes", + splats: "splats", }; +// Media classes for the user-list endpoint. The Meshes and Splats tabs don't +// use it — they call the session mesh/splat list endpoints instead. const getFilterMediaClass = ( filter: string, ): FilterMediaClasses[] | undefined => { @@ -81,14 +88,14 @@ const getFilterMediaClass = ( return [FilterMediaClasses.VIDEO]; case "audio": return [FilterMediaClasses.AUDIO]; - case "meshes": - return [FilterMediaClasses.DIMENSIONAL]; default: return [ FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO, FilterMediaClasses.AUDIO, FilterMediaClasses.DIMENSIONAL, + FilterMediaClasses.MESH, + FilterMediaClasses.SPLAT, ]; } }; @@ -168,6 +175,10 @@ export default function Library() { useEffect(() => () => useLibrarySelectionStore.getState().clear(), []); const api = useMemo(() => new GalleryModalApi(), []); + const mediaFilesApi = useMemo(() => new MediaFilesApi(), []); + // Keyset cursor for the Meshes / Splats tabs (their endpoints paginate by + // cursor, not page index). + const meshSplatCursorRef = useRef(undefined); // ── Folder store ────────────────────────────────────────────────────────── const folders = useLibraryFoldersStore((s) => s.folders); @@ -275,31 +286,57 @@ export default function Library() { isLoadingRef.current = true; setLoading(true); try { - const response = await api.listUserMediaFiles({ - username, - filter_media_classes: getFilterMediaClass(activeFilter), - include_user_uploads: true, - page_index: reset ? 0 : pageIndex, - page_size: PAGE_SIZE, - }); - if (response.success && response.data) { - const newItems = response.data - .filter( - (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])); - const current = response.pagination?.current ?? 0; - const total = response.pagination?.total_page_count ?? 1; - setPageIndex(current + 1); - setHasMore(current + 1 < total); + if (activeFilter === "meshes" || activeFilter === "splats") { + // The Meshes / Splats tabs use the session by-class list endpoints + // (server-scoped, cursor-paginated) instead of the user list. + const cursor = reset ? undefined : meshSplatCursorRef.current; + const response = + activeFilter === "splats" + ? await mediaFilesApi.ListSessionSplatMediaFiles({ + cursor, + page_size: PAGE_SIZE, + }) + : await mediaFilesApi.ListSessionMeshMediaFiles({ + cursor, + page_size: PAGE_SIZE, + }); + if (response.success && response.data) { + const newItems = response.data.map(mapRawToGalleryItem); + setAllItems((prev) => (reset ? newItems : [...prev, ...newItems])); + meshSplatCursorRef.current = + response.pagination?.maybe_next ?? undefined; + setHasMore( + newItems.length >= PAGE_SIZE && + !!response.pagination?.maybe_next, + ); + } + } else { + const response = await api.listUserMediaFiles({ + username, + filter_media_classes: getFilterMediaClass(activeFilter), + include_user_uploads: true, + page_index: reset ? 0 : pageIndex, + page_size: PAGE_SIZE, + }); + if (response.success && response.data) { + const newItems = response.data + .filter( + (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])); + const current = response.pagination?.current ?? 0; + const total = response.pagination?.total_page_count ?? 1; + setPageIndex(current + 1); + setHasMore(current + 1 < total); + } } } catch { // ignore @@ -308,7 +345,7 @@ export default function Library() { setInitialLoading(false); isLoadingRef.current = false; }, - [username, activeFilter, pageIndex, api], + [username, activeFilter, pageIndex, api, mediaFilesApi], ); // Initial load + filter change @@ -316,6 +353,7 @@ export default function Library() { if (!username) return; setAllItems([]); setPageIndex(0); + meshSplatCursorRef.current = undefined; setHasMore(true); setInitialLoading(true); isLoadingRef.current = false; @@ -706,6 +744,7 @@ export default function Library() { const refreshRoot = useCallback(() => { setAllItems([]); setPageIndex(0); + meshSplatCursorRef.current = undefined; setHasMore(true); setInitialLoading(true); isLoadingRef.current = false; @@ -1558,7 +1597,7 @@ function BulkThumb({ item }: { item: GalleryItem }) { const placeholderIcon = item.mediaClass === "video" ? faVideo - : item.mediaClass === "dimensional" + : is3DMediaClass(item.mediaClass) ? faCube : item.mediaClass === "audio" ? faMusic diff --git a/frontend/libs/api/src/lib/enums/QueryFilters.ts b/frontend/libs/api/src/lib/enums/QueryFilters.ts index 02277eaccb..382f8e8261 100644 --- a/frontend/libs/api/src/lib/enums/QueryFilters.ts +++ b/frontend/libs/api/src/lib/enums/QueryFilters.ts @@ -2,7 +2,10 @@ export enum FilterMediaClasses { AUDIO = "audio", IMAGE = "image", VIDEO = "video", + /** Deprecated coarse 3D class; legacy records only. New 3D media is MESH or SPLAT. */ DIMENSIONAL = "dimensional", + MESH = "mesh", + SPLAT = "splat", } export enum FilterMediaType {