Spaces:
Sleeping
Sleeping
| import { useState } from 'react' | |
| import { Tooltip } from '@radix-ui/themes' | |
| import { ArrowCounterClockwise, ButterflyIcon, CheckSquareIcon, FileTextIcon, FolderOpenIcon, GlobeHemisphereWestIcon, ImageSquareIcon, ListIcon, PencilSimpleIcon, PersonIcon, QuestionMarkIcon, SpeakerHigh, SpeakerSlash, SquareIcon, TerminalWindowIcon } from '@phosphor-icons/react' | |
| import type { WorldEntry, WorldHoverPreview, WorldObjectAsset, WorldSceneProject } from '../types/world' | |
| import { type ControllerMode, useDebugStore } from '../store/debug' | |
| import { useAudioStore } from '../store/audio' | |
| import { ViewerQuality } from '../types/world' | |
| import { AppButton } from './AppButton' | |
| import { ChromeThumbnail, chrome } from './AppChrome' | |
| const CONTROLLER_MODES: readonly { mode: ControllerMode; label: string }[] = [ | |
| { mode: 'fly', label: 'Fly' }, | |
| { mode: 'fps', label: 'FPS' }, | |
| ] | |
| const QUALITY_MODES = [ | |
| { mode: ViewerQuality.Low, label: 'Low' }, | |
| { mode: ViewerQuality.High, label: 'High' }, | |
| ] as const | |
| function nextMode<T>(items: readonly { mode: T }[], current: T) { | |
| const index = items.findIndex((item) => item.mode === current) | |
| return items[(index + 1) % items.length].mode | |
| } | |
| interface Props { | |
| worlds: WorldEntry[] | |
| activeSlug: string | |
| setActiveSlug: (slug: string) => void | |
| setEditing: (editing: boolean) => void | |
| compact?: boolean | |
| activeSceneProject?: WorldSceneProject | |
| activeSceneProjectEnabled: boolean | |
| onActiveSceneProjectToggle: () => void | |
| activeWorldVersionIndex?: number | |
| hoveredObjectAssetId?: string | null | |
| hoveredObjectInstanceId?: string | null | |
| onObjectHover?: (asset: WorldObjectAsset, hovering: boolean, instanceId?: string) => void | |
| onWorldHover?: (preview: WorldHoverPreview, hovering: boolean) => void | |
| onActiveWorldVersionChange: (index: number) => void | |
| } | |
| export function WorldSidebar({ | |
| worlds, | |
| activeSlug, | |
| setActiveSlug, | |
| setEditing, | |
| compact = false, | |
| activeSceneProject, | |
| activeSceneProjectEnabled, | |
| onActiveSceneProjectToggle, | |
| activeWorldVersionIndex, | |
| hoveredObjectAssetId, | |
| hoveredObjectInstanceId, | |
| onObjectHover, | |
| onWorldHover, | |
| onActiveWorldVersionChange, | |
| }: Props) { | |
| const [menuOpen, setMenuOpen] = useState(false) | |
| const butterfliesEnabled = useDebugStore((s) => s.butterfliesEnabled) | |
| const setButterfliesEnabled = useDebugStore((s) => s.setButterfliesEnabled) | |
| const canOpenLocalFolders = import.meta.env.DEV | |
| const selectWorld = (slug: string) => { | |
| setActiveSlug(slug) | |
| setMenuOpen(false) | |
| } | |
| const openClaudeTerminal = () => { | |
| fetch('/__open-claude-terminal').catch((error) => { | |
| console.warn('Could not open Claude terminal.', error) | |
| }) | |
| } | |
| const openWorldFolder = (slug: string) => { | |
| fetch(`/__open-world-folder?slug=${encodeURIComponent(slug)}`).catch((error) => { | |
| console.warn(`Could not open world folder for "${slug}".`, error) | |
| }) | |
| } | |
| const openAssetFolder = (slug: string, target: 'scene' | 'world-asset' | 'object-asset', asset?: string) => { | |
| const params = new URLSearchParams({ slug, target }) | |
| if (asset) params.set('asset', asset) | |
| fetch(`/__open-world-folder?${params.toString()}`).catch((error) => { | |
| console.warn(`Could not open ${target} folder for "${slug}".`, error) | |
| }) | |
| } | |
| return ( | |
| <aside className={`${chrome.enter} ${compact ? 'w-64 max-w-[calc(100vw-2rem)]' : 'w-full sm:w-64 max-h-[80vh]'} flex flex-col gap-1 whitespace-nowrap text-sm`}> | |
| <div className={`${chrome.bar} flex flex-shrink-0 items-center justify-between px-2 py-1 text-sm font-medium font-mono`}> | |
| <AppButton | |
| onClick={() => { | |
| if (!compact) setMenuOpen((open) => !open) | |
| }} | |
| className="min-w-0 flex-1 gap-2 px-1 truncate font-mono text-white opacity-100 hover:bg-transparent" | |
| aria-expanded={compact ? undefined : menuOpen} | |
| > | |
| {!compact && <ListIcon size={16} weight="regular" className="text-white/60 sm:hidden" />} | |
| <span>Loaded Models</span>{activeSlug && <span className="text-white/40 sm:hidden md:hidden">/ {activeSlug}</span>} | |
| </AppButton> | |
| <AppButton | |
| onClick={() => setButterfliesEnabled(!butterfliesEnabled)} | |
| active={butterfliesEnabled} | |
| className={`h-7 w-7 justify-center p-1 text-white ${butterfliesEnabled ? 'bg-white/15' : ''}`} | |
| aria-label={butterfliesEnabled ? 'Hide butterflies' : 'Show butterflies'} | |
| aria-pressed={butterfliesEnabled} | |
| title={butterfliesEnabled ? 'Hide butterflies' : 'Show butterflies'} | |
| > | |
| <ButterflyIcon size={16} weight={butterfliesEnabled ? 'fill' : 'regular'} /> | |
| </AppButton> | |
| <a | |
| href="https://github.com/Jeprary/SparkSplat" | |
| target="_blank" | |
| rel="noreferrer" | |
| className="inline-flex h-7 w-7 items-center justify-center rounded p-1 text-white opacity-80 transition-[background-color,opacity] hover:bg-white/10 hover:opacity-100" | |
| aria-label="Open SparkSplat GitHub repository" | |
| title="View source on GitHub" | |
| > | |
| <span className="text-sm leading-none"><QuestionMarkIcon size={16} weight="regular" /></span> | |
| </a> | |
| </div> | |
| <SidebarViewerControls /> | |
| {!compact && <div | |
| className={` | |
| ${chrome.panel} min-h-0 flex flex-1 flex-col gap-1 overflow-hidden p-1.5 | |
| transition-[opacity,transform,max-height] duration-200 ease-out sm:max-h-[calc(90vh-3rem)] sm:translate-y-0 sm:opacity-100 | |
| ${menuOpen ? 'max-h-[calc(90vh-3rem)] translate-y-0 opacity-100' : 'max-h-0 -translate-y-2 opacity-0 pointer-events-none sm:pointer-events-auto'} | |
| `} | |
| > | |
| <div className="w-full min-w-0 flex-1 overflow-y-auto overflow-x-hidden"> | |
| <div className="flex min-w-0 flex-col gap-0 pr-1"> | |
| {worlds.map(({ slug, project, world, worldVersions, objectAssets, sceneProject, sourceImageUrl }) => { | |
| const isActive = slug === activeSlug | |
| const name = project.display_name || slug | |
| const projectLoaded = isActive ? activeSceneProject : sceneProject | |
| const latestVersion = worldVersions[worldVersions.length - 1] | |
| const selectedVersionIndex = isActive ? activeWorldVersionIndex : latestVersion?.index | |
| const selectedVersion = worldVersions.find((version) => version.index === selectedVersionIndex) ?? latestVersion | |
| const displayWorld = selectedVersion?.world ?? world | |
| const hasSplatFile = Boolean(displayWorld && (displayWorld.assets.splats.ply_url || Object.values(displayWorld.assets.splats.spz_urls || {}).some(Boolean))) | |
| const hasWorldRow = Boolean(selectedVersion || hasSplatFile) | |
| const worldLoading = Boolean(selectedVersion && !selectedVersion.complete && isLoadingStatus(selectedVersion.status)) | |
| const sourcePreview: WorldHoverPreview = { | |
| slug, | |
| imageUrl: sourceImageUrl, | |
| alt: `${name} source image`, | |
| } | |
| const activeWorldPreview: WorldHoverPreview = { | |
| slug, | |
| imageUrl: selectedVersion?.plateImageUrl, | |
| alt: `${name} world generation image`, | |
| } | |
| return ( | |
| <div key={slug} className="rounded"> | |
| <div | |
| className={` | |
| min-w-0 flex items-center gap-1 rounded | |
| ${isActive ? 'border-white/50 bg-white/20' : ''} | |
| `} | |
| onMouseEnter={() => { | |
| if (!isActive) onWorldHover?.(sourcePreview, true) | |
| }} | |
| onMouseLeave={() => { | |
| if (!isActive) onWorldHover?.(sourcePreview, false) | |
| }} | |
| > | |
| <AppButton | |
| onClick={() => selectWorld(slug)} | |
| active={isActive} | |
| className={` | |
| min-w-0 flex flex-1 items-center rounded py-2 text-left | |
| ${isActive ? 'hover:bg-transparent' : ''} | |
| `} | |
| > | |
| <span className="block min-w-0 flex-1 truncate text-sm font-medium leading-tight text-white">{slug}</span> | |
| </AppButton> | |
| {canOpenLocalFolders && isActive && ( | |
| <Tooltip | |
| content={`Open the local folder for ${name}`} | |
| delayDuration={0} | |
| side="right" | |
| > | |
| <AppButton | |
| onClick={() => openWorldFolder(slug)} | |
| className="h-8 w-8 flex-shrink-0 justify-center text-white" | |
| aria-label={`Open local folder for ${name}`} | |
| title={`Open local folder for ${name}`} | |
| > | |
| <FolderOpenIcon size={15} weight="regular" /> | |
| </AppButton> | |
| </Tooltip> | |
| )} | |
| </div> | |
| <div | |
| className={` | |
| overflow-hidden transition-all duration-300 ease-in-out | |
| ${isActive ? 'max-h-[32rem]' : 'max-h-0'} | |
| `} | |
| > | |
| <div className="mt-1 flex min-w-0 flex-col"> | |
| {isActive && projectLoaded && ( | |
| <div className="group flex min-w-0 items-center gap-1 rounded px-2 py-1 text-left text-white/80"> | |
| <span className="relative flex h-7 w-7 flex-shrink-0 items-center justify-center rounded bg-white/10 text-white/45 ring-1 ring-white/10"> | |
| <FileTextIcon size={14} weight="regular" /> | |
| <FileExtensionBadge extension=".json" /> | |
| </span> | |
| <span className="min-w-0 flex-1 truncate text-xs font-medium leading-tight text-white/80"> | |
| scene.json | |
| </span> | |
| {canOpenLocalFolders && ( | |
| <AppButton | |
| onClick={() => openAssetFolder(slug, 'scene')} | |
| className="h-7 w-7 flex-shrink-0 justify-center p-1 text-white opacity-0 transition-opacity group-hover:opacity-90 focus-visible:opacity-100 hover:opacity-100" | |
| aria-label={`Open world folder for ${name}`} | |
| title={`Open world folder for ${name}`} | |
| > | |
| <FolderOpenIcon size={14} weight="regular" /> | |
| </AppButton> | |
| )} | |
| <AppButton | |
| onClick={onActiveSceneProjectToggle} | |
| active={activeSceneProjectEnabled} | |
| className="h-6 w-6 flex-shrink-0 justify-center p-1 text-white/70" | |
| aria-label={activeSceneProjectEnabled ? 'Disable scene.json' : 'Enable scene.json'} | |
| aria-pressed={activeSceneProjectEnabled} | |
| title={activeSceneProjectEnabled ? 'Disable scene.json' : 'Enable scene.json'} | |
| > | |
| {activeSceneProjectEnabled ? ( | |
| <CheckSquareIcon size={14} weight="bold" /> | |
| ) : ( | |
| <SquareIcon size={14} weight="regular" /> | |
| )} | |
| </AppButton> | |
| </div> | |
| )} | |
| {hasWorldRow && ( | |
| <div | |
| className="group flex min-w-0 items-center gap-1 rounded" | |
| onMouseEnter={() => onWorldHover?.(activeWorldPreview, true)} | |
| onMouseLeave={() => onWorldHover?.(activeWorldPreview, false)} | |
| > | |
| <div className="min-w-0 flex flex-1 items-center gap-2 rounded px-2 py-1 text-left text-white opacity-80"> | |
| <span className="relative flex h-7 w-7 flex-shrink-0"> | |
| {activeWorldPreview.imageUrl ? ( | |
| <ChromeThumbnail thumbnailUrl={activeWorldPreview.imageUrl} alt={activeWorldPreview.alt} /> | |
| ) : ( | |
| <span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded bg-white/10 text-white/45 ring-1 ring-white/10"> | |
| <GlobeHemisphereWestIcon size={14} weight="regular" /> | |
| </span> | |
| )} | |
| {hasSplatFile && <FileExtensionBadge extension={displayWorld?.assets.splats.ply_url ? (displayWorld.assets.splats.ply_url.match(/\.[a-zA-Z]+$/)?.[0]?.toLowerCase() ?? ".ply") : ".spz"} />} | |
| </span> | |
| <span className="min-w-0 flex-1 text-white/85 text-xs font-semibold leading-tight truncate"> | |
| {slug} | |
| </span> | |
| {worldLoading && <LoadingSpinner />} | |
| {isActive && worldVersions.length > 1 && selectedVersion && ( | |
| <select | |
| value={selectedVersion.index} | |
| className="h-5 flex-shrink-0 rounded border border-white/10 bg-white/5 px-1 text-[10px] leading-none text-white/60" | |
| aria-label={`Select world version for ${name}`} | |
| onChange={(event) => onActiveWorldVersionChange(Number(event.target.value))} | |
| > | |
| {worldVersions.map((version) => ( | |
| <option key={version.index} value={version.index}> | |
| {version.label} | |
| </option> | |
| ))} | |
| </select> | |
| )} | |
| {!isActive && worldVersions.length > 1 && ( | |
| <span className="flex-shrink-0 rounded border border-white/10 bg-white/5 px-1.5 py-0.5 text-[10px] leading-none text-white/45"> | |
| {worldVersions.length} | |
| </span> | |
| )} | |
| </div> | |
| {canOpenLocalFolders && ( | |
| <AppButton | |
| onClick={() => openAssetFolder(slug, 'world-asset')} | |
| className="h-7 w-7 flex-shrink-0 justify-center p-1 text-white opacity-0 transition-opacity group-hover:opacity-90 focus-visible:opacity-100 hover:opacity-100" | |
| aria-label={`Open world asset folder for ${name}`} | |
| title={`Open world asset folder for ${name}`} | |
| > | |
| <FolderOpenIcon size={14} weight="regular" /> | |
| </AppButton> | |
| )} | |
| </div> | |
| )} | |
| {objectAssets.map((obj) => { | |
| const objectLoading = !obj.complete && isLoadingStatus(obj.status) | |
| const objectPreview: WorldHoverPreview = { | |
| slug: obj.assetId, | |
| imageUrl: obj.referenceImageUrl ?? obj.thumbnailUrl, | |
| alt: `${obj.name} reference image`, | |
| } | |
| return ( | |
| <div | |
| key={obj.assetId} | |
| className={`flex min-w-0 items-center gap-2 rounded px-2 py-1 text-left group ${ | |
| hoveredObjectAssetId === obj.assetId && !hoveredObjectInstanceId ? 'bg-white/10 opacity-100' : '' | |
| } ${obj.complete ? '' : 'opacity-80'}`} | |
| onMouseEnter={() => { | |
| if (obj.complete) { | |
| onObjectHover?.(obj, true) | |
| } else { | |
| onWorldHover?.(objectPreview, true) | |
| } | |
| }} | |
| onMouseLeave={() => { | |
| if (obj.complete) { | |
| onObjectHover?.(obj, false) | |
| } else { | |
| onWorldHover?.(objectPreview, false) | |
| } | |
| }} | |
| > | |
| <span className="relative flex h-7 w-7 flex-shrink-0"> | |
| <ChromeThumbnail thumbnailUrl={obj.thumbnailUrl ?? obj.referenceImageUrl} alt={obj.name} /> | |
| {obj.complete && <FileExtensionBadge extension=".obj" />} | |
| </span> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block text-white/80 text-xs font-medium leading-tight truncate"> | |
| {obj.name} | |
| </span> | |
| </span> | |
| {objectLoading ? ( | |
| <LoadingSpinner /> | |
| ) : obj.index !== undefined && obj.index > 0 && obj.variantLabel && ( | |
| <span className="flex-shrink-0 rounded border border-white/10 bg-white/5 px-1.5 py-0.5 text-[10px] leading-none text-white/50"> | |
| {obj.variantLabel} | |
| </span> | |
| )} | |
| {canOpenLocalFolders && ( | |
| <AppButton | |
| onClick={() => openAssetFolder(slug, 'object-asset', obj.baseObjectId)} | |
| className="h-7 w-7 flex-shrink-0 justify-center p-1 text-white opacity-0 transition-opacity group-hover:opacity-90 focus-visible:opacity-100 hover:opacity-100" | |
| aria-label={`Open asset folder for ${obj.name}`} | |
| title={`Open asset folder for ${obj.name}`} | |
| > | |
| <FolderOpenIcon size={14} weight="regular" /> | |
| </AppButton> | |
| )} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| </div>} | |
| </aside> | |
| ) | |
| } | |
| function SidebarViewerControls() { | |
| const muted = useAudioStore((s) => s.muted) | |
| const toggleMuted = useAudioStore((s) => s.toggleMuted) | |
| const resetObjects = useDebugStore((s) => s.resetObjects) | |
| const controllerMode = useDebugStore((s) => s.controllerMode) | |
| const setControllerMode = useDebugStore((s) => s.setControllerMode) | |
| const viewerQuality = useDebugStore((s) => s.viewerQuality) | |
| const setViewerQuality = useDebugStore((s) => s.setViewerQuality) | |
| const currentControllerMode = CONTROLLER_MODES.find((item) => item.mode === controllerMode) ?? CONTROLLER_MODES[0] | |
| const currentQuality = QUALITY_MODES.find((item) => item.mode === viewerQuality) ?? QUALITY_MODES[0] | |
| return ( | |
| <div className={`${chrome.bar} flex flex-shrink-0 items-center gap-1 text-sm font-medium font-mono`}> | |
| <Tooltip content="Reset" delayDuration={0} side="right"> | |
| <AppButton | |
| onClick={resetObjects} | |
| className="h-7 w-7 justify-center p-1 text-white" | |
| aria-label="Reset" | |
| title="Reset" | |
| > | |
| <ArrowCounterClockwise size={16} weight="bold" /> | |
| </AppButton> | |
| </Tooltip> | |
| <Tooltip content={muted ? 'Unmute' : 'Mute'} delayDuration={0} side="right"> | |
| <AppButton | |
| onClick={toggleMuted} | |
| className="h-7 w-7 justify-center p-1 text-white" | |
| aria-label={muted ? 'Unmute' : 'Mute'} | |
| title={muted ? 'Unmute' : 'Mute'} | |
| > | |
| {muted ? <SpeakerSlash size={16} weight="fill" /> : <SpeakerHigh size={16} weight="fill" />} | |
| </AppButton> | |
| </Tooltip> | |
| <Tooltip content="Change controller" delayDuration={0} side="right"> | |
| <AppButton | |
| onClick={() => setControllerMode(nextMode(CONTROLLER_MODES, controllerMode))} | |
| className="h-7 w-20 justify-center gap-1 flex border flex-1 border-white/20 text-white" | |
| aria-label="Change controller" | |
| title="Change controller" | |
| > | |
| <PersonIcon size={15} weight="fill" className="flex-shrink-0 text-white/45" /> | |
| <span>{currentControllerMode.label}</span> | |
| </AppButton> | |
| </Tooltip> | |
| <Tooltip content="Change quality" delayDuration={0} side="right"> | |
| <AppButton | |
| onClick={() => setViewerQuality(nextMode(QUALITY_MODES, viewerQuality))} | |
| className="h-7 w-16 justify-center gap-1 flex border flex-1 border-white/20 text-white" | |
| aria-label="Change quality" | |
| title="Change quality" | |
| > | |
| <ImageSquareIcon size={15} weight="fill" className="flex-shrink-0 text-white/45" /> | |
| <span>{currentQuality.label}</span> | |
| </AppButton> | |
| </Tooltip> | |
| </div> | |
| ) | |
| } | |
| function FileExtensionBadge({ extension }: { extension: string }) { | |
| return ( | |
| <span className="pointer-events-none absolute bottom-0 right-0 rounded-sm bg-black/70 px-px font-mono text-[6px] font-semibold leading-[7px] text-white/70 ring-1 ring-white/10"> | |
| {extension} | |
| </span> | |
| ) | |
| } | |
| function LoadingSpinner() { | |
| return ( | |
| <span className="pointer-events-none h-3 w-3 flex-shrink-0 rounded-full border border-white/25 border-t-white/90 animate-spin" /> | |
| ) | |
| } | |
| function isLoadingStatus(status?: string) { | |
| if (!status) return true | |
| return ['queued', 'submitted', 'running', 'processing', 'in_progress'].includes(status.toLowerCase()) | |
| } | |