mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
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:
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user