Frontend: recognize mesh/splat media classes alongside legacy dimensional

The July backend revamp split the deprecated "dimensional" 3D class into
"mesh" and "splat"; new records are written with the split classes while
legacy rows keep "dimensional" until backfilled. Extends every media-class
branch to accept all three:

- Dashboard: filter_media_classes payloads (UserProfile, UserCreations,
  ExploreMedia + its 3D filter option) now request mesh/splat too — these
  views were silently missing all newly generated 3D content; cover-image
  thumbnail branch, class labels/icons (MediaDetailModal, MediaCard)
- Webapp: library cover-screenshot filter, download extension map
  (mesh -> .glb, splat -> .spz)
- Website: GalleryCard/useGalleryData/lightbox/library 3D checks, download
  extension map
- gallery-modal: cover-screenshot filter, bulk-select placeholder icon,
  badge label
- lightbox-modal: 3D viewer routing + loading-spinner suppression
- generation-list: GenerationMediaClass union + batchNoun gain mesh/splat;
  thumbnail/label/model-split branches use is3DMediaClass (the deliberate
  task->dimensional bridge in useGenerationJobs is unchanged — the Tauri
  task enum is intentionally unsplit)
- pagescene: FilterMediaClasses enum gains MESH/SPLAT (aligns with
  QueryFilters in @storyteller/api)
This commit is contained in:
Tanish Manku
2026-07-13 22:00:20 -07:00
parent 283b4ab04f
commit 9114ec7fad
20 changed files with 103 additions and 41 deletions
@@ -116,8 +116,12 @@ export function getMediaThumbnailUrl(
}
}
// 3D/dimensional: use cover image URL
if (item.media_class === "dimensional") {
// 3D (mesh/splat, plus legacy pre-split "dimensional"): use cover image URL
if (
item.media_class === "mesh" ||
item.media_class === "splat" ||
item.media_class === "dimensional"
) {
if ("cover_image" in item && item.cover_image) {
if (item.cover_image.maybe_cover_image_public_bucket_url) {
return item.cover_image.maybe_cover_image_public_bucket_url;
@@ -28,6 +28,8 @@ interface MediaCardProps {
const classIcons: Record<string, typeof IconPhoto> = {
video: IconVideo,
dimensional: IconCube,
mesh: IconCube,
splat: IconCube,
};
function formatDuration(ms: number): string {
@@ -118,7 +120,9 @@ export function MediaCard({
{/* Top-left: media class + upload indicator */}
<div className="absolute top-1.5 left-1.5 z-10 flex items-center gap-1 flex-wrap">
<span className="px-2 py-1 rounded-md bg-black/40 text-[10px] font-medium text-white backdrop-blur-sm uppercase">
{mediaClass === "dimensional" ? "3D" : mediaClass || "Unknown"}
{mediaClass === "dimensional" || mediaClass === "mesh" || mediaClass === "splat"
? "3D"
: mediaClass || "Unknown"}
</span>
{isUserUpload && (
<span
@@ -41,11 +41,15 @@ const classLabels: Record<string, string> = {
image: "Image",
video: "Video",
dimensional: "3D",
mesh: "3D Object",
splat: "3D World",
};
const classIcons: Record<string, typeof IconPhoto> = {
video: IconVideo,
dimensional: IconCube,
mesh: IconCube,
splat: IconCube,
};
function formatModelType(model: string): string {
@@ -26,7 +26,9 @@ const MEDIA_CLASS_OPTIONS = [
{ value: "", label: "All" },
{ value: "image", label: "Image" },
{ value: "video", label: "Video" },
{ value: "dimensional", label: "3D" },
// "dimensional" is the deprecated pre-split class; rows persist until the
// backfill lands, so the 3D filter matches all three classes.
{ value: "mesh,splat,dimensional", label: "3D" },
] as const;
const PAGE_SIZE = 24;
@@ -74,7 +76,8 @@ export function ExploreMedia() {
const resp = await api.ListMediaFiles({
page_size: PAGE_SIZE,
cursor,
filter_media_classes: filterMediaClasses || "image,video,dimensional",
filter_media_classes:
filterMediaClasses || "image,video,mesh,splat,dimensional",
filter_media_type: filterMediaType || undefined,
include_user_uploads: includeUserUploads || undefined,
});
@@ -46,7 +46,7 @@ export function UserCreations() {
const resp = await mediaApi.ListUserMediaFiles(username, {
page_index: pageIndex,
page_size: PAGE_SIZE,
filter_media_classes: ["image", "video", "dimensional"],
filter_media_classes: ["image", "video", "mesh", "splat", "dimensional"],
});
if (cancelledRef.current) return;
@@ -84,7 +84,7 @@ export function UserCreations() {
mediaApi.ListUserMediaFiles(username, {
page_index: 0,
page_size: PAGE_SIZE,
filter_media_classes: ["image", "video", "dimensional"],
filter_media_classes: ["image", "video", "mesh", "splat", "dimensional"],
}),
]);
@@ -174,7 +174,7 @@ export function UserProfile() {
modApi.UserLookup(username),
mediaApi.ListUserMediaFiles(username, {
page_size: 24,
filter_media_classes: ["image", "video", "dimensional"],
filter_media_classes: ["image", "video", "mesh", "splat", "dimensional"],
}),
]);
@@ -5,7 +5,11 @@ const EXT_BY_MEDIA_CLASS: Record<string, string> = {
image: "png",
video: "mp4",
audio: "mp3",
// "dimensional" is the deprecated pre-split 3D class (rows persist until
// the backfill lands); mesh/splat are its replacements.
dimensional: "glb",
mesh: "glb",
splat: "spz",
};
function extensionForUrl(url: string, mediaClass?: string | null): string {
@@ -377,9 +377,12 @@ export default function Library() {
(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.
// 3D-classed items (mesh/splat, or legacy pre-split
// "dimensional") whose asset is actually a .png.
!(
item.media_class === "dimensional" &&
(item.media_class === "dimensional" ||
item.media_class === "mesh" ||
item.media_class === "splat") &&
!is3DModelUrl(item.media_links?.cdn_url)
),
)
@@ -128,7 +128,10 @@ export const GalleryCard = memo(function GalleryCard({
const [isRecreating, setIsRecreating] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const isVideo = item.mediaClass === "video";
const is3D = item.mediaClass === "dimensional";
const is3D =
item.mediaClass === "dimensional" ||
item.mediaClass === "mesh" ||
item.mediaClass === "splat";
const recreateMediaClass: RecreateMediaClass | null = isVideo
? "video"
: is3D
@@ -194,18 +197,8 @@ export const GalleryCard = memo(function GalleryCard({
? getCreatorIconPathForModelId(item.modelId)
: null;
const mediaIcon =
item.mediaClass === "video"
? faVideo
: item.mediaClass === "dimensional"
? faCube
: faImage;
const mediaLabel =
item.mediaClass === "video"
? "Video"
: item.mediaClass === "dimensional"
? "3D"
: "Image";
const mediaIcon = item.mediaClass === "video" ? faVideo : is3D ? faCube : faImage;
const mediaLabel = item.mediaClass === "video" ? "Video" : is3D ? "3D" : "Image";
const handleLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
@@ -29,7 +29,10 @@ const getLabel = (item: any) => {
case "video":
return "Video Generation";
case "dimensional":
case "mesh":
return "3D Mesh";
case "splat":
return "3D World";
default:
return "Generation";
}
@@ -54,8 +57,12 @@ export function useGalleryData(options: {
const api = useMemo(() => new GalleryModalApi(), []);
const mapApiItem = useCallback((item: any): GalleryItem => {
const isDimensional = item.media_class === "dimensional";
const thumbnail = isDimensional
// 3D classes: mesh/splat, plus the legacy pre-split "dimensional".
const is3D =
item.media_class === "dimensional" ||
item.media_class === "mesh" ||
item.media_class === "splat";
const thumbnail = is3D
? null
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
@@ -273,7 +273,9 @@ export function Lightbox({
: propMediaClass === "video";
const is3D = selectedImageUrl
? is3DModelUrl(selectedImageUrl)
: propMediaClass === "dimensional";
: propMediaClass === "dimensional" ||
propMediaClass === "mesh" ||
propMediaClass === "splat";
// Keyboard navigation
useEffect(() => {
@@ -4,7 +4,11 @@ import { toast } from "../components/toast/toast";
const EXT_BY_MEDIA_CLASS: Record<string, string> = {
image: "png",
video: "mp4",
// "dimensional" is the deprecated pre-split 3D class (rows persist until
// the backfill lands); mesh/splat are its replacements.
dimensional: "glb",
mesh: "glb",
splat: "spz",
};
function extensionForUrl(url: string, mediaClass?: string | null): string {
@@ -72,7 +72,10 @@ const getLabel = (item: any) => {
case "video":
return "Video Generation";
case "dimensional":
case "mesh":
return "3D Mesh";
case "splat":
return "3D World";
default:
return "Generation";
}
@@ -119,10 +122,14 @@ export default function Library() {
// Map API item to GalleryItem
const mapApiItem = useCallback((item: any): GalleryItem => {
const isDimensional = item.media_class === "dimensional";
// Meshes don't have image thumbnails — show cube icon instead
// 3D classes: mesh/splat, plus the legacy pre-split "dimensional".
const is3D =
item.media_class === "dimensional" ||
item.media_class === "mesh" ||
item.media_class === "splat";
// 3D assets don't have image thumbnails — show cube icon instead
// For videos, getMediaThumbnail tries animated preview first, then template, then cdn_url
const thumbnail = isDimensional
const thumbnail = is3D
? null
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
@@ -406,7 +406,11 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{/* Media class badge on hover — bottom-left */}
{!disableTooltipAndBadge && item.mediaClass && (
<div className="pointer-events-none absolute left-2 bottom-2 z-20 rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
{item.mediaClass === "dimensional" ? "3D" : item.mediaClass}
{item.mediaClass === "dimensional" ||
item.mediaClass === "mesh" ||
item.mediaClass === "splat"
? "3D"
: item.mediaClass}
</div>
)}
{/* Upload badge — bottom-right (always visible, even in select mode) */}
@@ -963,9 +963,12 @@ export const GalleryModal = React.memo(
(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.
// 3D-classed items (mesh/splat, or legacy pre-split
// "dimensional") whose asset is actually a .png.
!(
item.media_class === "dimensional" &&
(item.media_class === "dimensional" ||
item.media_class === "mesh" ||
item.media_class === "splat") &&
!is3DModelUrl(item.media_links?.cdn_url)
),
)
@@ -3006,7 +3009,9 @@ export const GalleryModal = React.memo(
const placeholderIcon =
si.mediaClass === "video"
? faVideo
: si.mediaClass === "dimensional"
: si.mediaClass === "dimensional" ||
si.mediaClass === "mesh" ||
si.mediaClass === "splat"
? faCube
: faImage;
return (
@@ -5,7 +5,10 @@ export type GenerationMediaClass =
| "image"
| "video"
| "audio"
| "dimensional";
// Deprecated pre-split 3D class; rows persist until the backfill lands.
| "dimensional"
| "mesh"
| "splat";
// The backend used to file all 3D media under the (now deprecated) coarse
// "dimensional" class; new records are written as "mesh" (3D models) or
@@ -26,7 +29,10 @@ export function batchNoun(mediaClass: GenerationMediaClass): string {
case "audio":
return "audio clips";
case "dimensional":
case "mesh":
return "3D models";
case "splat":
return "3D worlds";
default:
return "images";
}
@@ -6,6 +6,7 @@ import {
} from "@storyteller/api";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import type { GalleryItem } from "./types";
import { is3DMediaClass } from "./types";
const PAGE_SIZE = 40;
@@ -19,7 +20,10 @@ const getLabel = (item: any) => {
case "audio":
return "Audio Generation";
case "dimensional":
case "mesh":
return "3D Mesh";
case "splat":
return "3D World";
default:
return "Generation";
}
@@ -69,7 +73,7 @@ export function useGalleryData(options: {
const thumbnail =
item.media_class === "audio"
? null
: item.media_class === "dimensional"
: is3DMediaClass(item.media_class)
? get3DCoverThumbnail(item)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
@@ -117,11 +121,11 @@ export function useGalleryData(options: {
item.media_type !== FilterMediaType.SCENE_JSON &&
!(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
// show each other's generations. Non-3D items and items
// without a known model are left untouched.
!(
modelIdSet &&
item.media_class === "dimensional" &&
is3DMediaClass(item.media_class) &&
item.maybe_model_type &&
!modelIdSet.has(item.maybe_model_type)
),
@@ -14,6 +14,7 @@ import type {
GenerationMediaClass,
InProgressJob,
} from "./types";
import { is3DMediaClass } from "./types";
// Maps JobsApi's recent-jobs stream into the canonical feed shapes
// (InProgressJob / FailedJob / newly-completed GalleryItem). HTTP-based, so
@@ -239,7 +240,7 @@ function jobToGalleryItem(
const thumbnail =
mediaClass === "audio"
? null
: mediaClass === "dimensional"
: is3DMediaClass(mediaClass)
? get3DCoverThumbnail(result)
: getMediaThumbnail(result.media_links, mediaClass, {
size: THUMBNAIL_SIZES.LARGE,
@@ -282,7 +283,7 @@ async function expandBatchItems(
const thumbnail =
item.mediaClass === "audio"
? null
: item.mediaClass === "dimensional"
: is3DMediaClass(item.mediaClass)
? get3DCoverThumbnail(file)
: getMediaThumbnail(file.media_links, item.mediaClass, {
size: THUMBNAIL_SIZES.LARGE,
@@ -583,7 +583,9 @@ export function LightboxModal({
<div className="flex h-full w-full items-center justify-center bg-black/30">
<span className="text-base-fg/60">Image not available</span>
</div>
) : mediaClass === "dimensional" ? (
) : mediaClass === "dimensional" ||
mediaClass === "mesh" ||
mediaClass === "splat" ? (
<div className="h-full w-full">
<Viewer3D
key={selectedImageUrl}
@@ -708,6 +710,8 @@ export function LightboxModal({
{!mediaLoaded &&
selectedImageUrl &&
mediaClass !== "dimensional" &&
mediaClass !== "mesh" &&
mediaClass !== "splat" &&
mediaClass !== "audio" && (
<div className="absolute inset-0 bg-ui-panel flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-base-fg" />
@@ -1,8 +1,11 @@
// Media class filter (audio / image / video / dimensional). Used in
// Media class filter (audio / image / video / 3D classes). Used in
// asset browsing and weight-listing queries.
export enum FilterMediaClasses {
AUDIO = "audio",
IMAGE = "image",
VIDEO = "video",
// Deprecated pre-split 3D class; rows persist until the backfill lands.
DIMENSIONAL = "dimensional",
MESH = "mesh",
SPLAT = "splat",
}