feat: support open 3d viewer in media asset panel (#6703)

## Summary

Add support for previewing 3D assets directly in the Media Asset Panel.

## Changes

- **3D Asset Preview**: Clicking on 3D assets (`.glb`, `.gltf`, etc.) in
the Media Asset Panel now opens the full
  3D viewer

## Screenshots



https://github.com/user-attachments/assets/38808712-acc8-42aa-9f11-8d8bf2387b20

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6703-feat-support-open-3d-viewer-in-media-asset-panel-2ab6d73d3650811dbff9ecb570a0a878)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2025-11-14 14:31:58 -05:00
committed by GitHub
parent b23a92b442
commit a9f416233d
4 changed files with 114 additions and 11 deletions

View File

@@ -37,6 +37,7 @@
v-model:background-render-mode="viewer.backgroundRenderMode.value" v-model:background-render-mode="viewer.backgroundRenderMode.value"
v-model:fov="viewer.fov.value" v-model:fov="viewer.fov.value"
:has-background-image="viewer.hasBackgroundImage.value" :has-background-image="viewer.hasBackgroundImage.value"
:disable-background-upload="viewer.isStandaloneMode.value"
@update-background-image="viewer.handleBackgroundImageUpdate" @update-background-image="viewer.handleBackgroundImageUpdate"
/> />
</div> </div>
@@ -91,13 +92,15 @@ import LightControls from '@/components/load3d/controls/viewer/ViewerLightContro
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue' import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue' import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag' import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import { t } from '@/i18n' import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService' import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{ const props = defineProps<{
node: LGraphNode node?: LGraphNode
modelUrl?: string
}>() }>()
const viewerContentRef = ref<HTMLDivElement>() const viewerContentRef = ref<HTMLDivElement>()
@@ -106,20 +109,30 @@ const mainContentRef = ref<HTMLDivElement>()
const maximized = ref(false) const maximized = ref(false)
const mutationObserver = ref<MutationObserver | null>(null) const mutationObserver = ref<MutationObserver | null>(null)
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node)) const isStandaloneMode = !props.node && props.modelUrl
const viewer = props.node
? useLoad3dService().getOrCreateViewer(toRaw(props.node))
: useLoad3dViewer()
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } = const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({ useLoad3dDrag({
onModelDrop: async (file) => { onModelDrop: async (file) => {
await viewer.handleModelDrop(file) await viewer.handleModelDrop(file)
}, },
disabled: viewer.isPreview disabled: viewer.isPreview.value || isStandaloneMode
}) })
onMounted(async () => { onMounted(async () => {
const source = useLoad3dService().getLoad3d(props.node) if (!containerRef.value) return
if (source && containerRef.value) {
await viewer.initializeViewer(containerRef.value, source) if (isStandaloneMode && props.modelUrl) {
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
} else if (props.node) {
const source = useLoad3dService().getLoad3d(props.node)
if (source) {
await viewer.initializeViewer(containerRef.value, source)
}
} }
if (viewerContentRef.value) { if (viewerContentRef.value) {
@@ -150,7 +163,9 @@ onMounted(async () => {
}) })
const handleCancel = () => { const handleCancel = () => {
viewer.restoreInitialState() if (!isStandaloneMode) {
viewer.restoreInitialState()
}
useDialogStore().closeDialog() useDialogStore().closeDialog()
} }

View File

@@ -14,7 +14,7 @@
</label> </label>
</div> </div>
<div v-if="!hasBackgroundImage"> <div v-if="!hasBackgroundImage && !disableBackgroundUpload">
<Button <Button
severity="secondary" severity="secondary"
:label="$t('load3d.uploadBackgroundImage')" :label="$t('load3d.uploadBackgroundImage')"
@@ -74,6 +74,7 @@ const backgroundRenderMode = defineModel<'tiled' | 'panorama'>(
defineProps<{ defineProps<{
hasBackgroundImage?: boolean hasBackgroundImage?: boolean
disableBackgroundUpload?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -163,6 +163,7 @@ import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue' import TextButton from '@/components/button/TextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue' import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue' import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue' import TabList from '@/components/tab/TabList.vue'
@@ -176,6 +177,7 @@ import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAs
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore' import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil' import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -332,6 +334,25 @@ const handleAssetSelect = (asset: AssetItem) => {
} }
const handleZoomClick = (asset: AssetItem) => { const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
if (mediaType === '3D') {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
})
return
}
currentGalleryAssetId.value = asset.id currentGalleryAssetId.value = asset.id
const index = displayAssets.value.findIndex((a) => a.id === asset.id) const index = displayAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) { if (index !== -1) {

View File

@@ -27,7 +27,11 @@ interface Load3dViewerState {
materialMode: MaterialMode materialMode: MaterialMode
} }
export const useLoad3dViewer = (node: LGraphNode) => { /**
* @param node Optional node - if provided, viewer works in node mode with apply/restore
* If not provided, viewer works in standalone mode for asset preview
*/
export const useLoad3dViewer = (node?: LGraphNode) => {
const backgroundColor = ref('') const backgroundColor = ref('')
const showGrid = ref(true) const showGrid = ref(true)
const cameraType = ref<CameraType>('perspective') const cameraType = ref<CameraType>('perspective')
@@ -40,6 +44,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
const materialMode = ref<MaterialMode>('original') const materialMode = ref<MaterialMode>('original')
const needApplyChanges = ref(true) const needApplyChanges = ref(true)
const isPreview = ref(false) const isPreview = ref(false)
const isStandaloneMode = ref(false)
let load3d: Load3d | null = null let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null let sourceLoad3d: Load3d | null = null
@@ -166,11 +171,14 @@ export const useLoad3dViewer = (node: LGraphNode) => {
} }
}) })
/**
* Initialize viewer in node mode (with source Load3d)
*/
const initializeViewer = async ( const initializeViewer = async (
containerRef: HTMLElement, containerRef: HTMLElement,
source: Load3d source: Load3d
) => { ) => {
if (!containerRef) return if (!containerRef || !node) return
sourceLoad3d = source sourceLoad3d = source
@@ -263,6 +271,52 @@ export const useLoad3dViewer = (node: LGraphNode) => {
} }
} }
/**
* Initialize viewer in standalone mode (for asset preview)
*/
const initializeStandaloneViewer = async (
containerRef: HTMLElement,
modelUrl: string
) => {
if (!containerRef) return
try {
isStandaloneMode.value = true
const mockNode = {
widgets: [
{ name: 'width', value: 800 },
{ name: 'height', value: 600 }
],
properties: {},
graph: null,
type: 'AssetPreview'
} as unknown as LGraphNode
load3d = new Load3d(containerRef, {
node: mockNode,
disablePreview: true,
isViewerMode: true
})
await load3d.loadModel(modelUrl)
backgroundColor.value = '#282828'
showGrid.value = true
cameraType.value = 'perspective'
fov.value = 75
lightIntensity.value = 1
backgroundRenderMode.value = 'tiled'
upDirection.value = 'original'
materialMode.value = 'original'
isPreview.value = true
} catch (error) {
console.error('Error initializing standalone 3D viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
}
}
const exportModel = async (format: string) => { const exportModel = async (format: string) => {
if (!load3d) return if (!load3d) return
@@ -289,6 +343,8 @@ export const useLoad3dViewer = (node: LGraphNode) => {
} }
const restoreInitialState = () => { const restoreInitialState = () => {
if (!node) return
const nodeValue = node const nodeValue = node
needApplyChanges.value = false needApplyChanges.value = false
@@ -324,7 +380,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
} }
const applyChanges = async () => { const applyChanges = async () => {
if (!sourceLoad3d || !load3d) return false if (!node || !sourceLoad3d || !load3d) return false
const viewerCameraState = load3d.getCameraState() const viewerCameraState = load3d.getCameraState()
const nodeValue = node const nodeValue = node
@@ -378,6 +434,10 @@ export const useLoad3dViewer = (node: LGraphNode) => {
return return
} }
if (!node) {
return
}
try { try {
const resourceFolder = const resourceFolder =
(node.properties['Resource Folder'] as string) || '' (node.properties['Resource Folder'] as string) || ''
@@ -403,6 +463,10 @@ export const useLoad3dViewer = (node: LGraphNode) => {
return return
} }
if (!node) {
return
}
try { try {
const resourceFolder = const resourceFolder =
(node.properties['Resource Folder'] as string) || '' (node.properties['Resource Folder'] as string) || ''
@@ -460,9 +524,11 @@ export const useLoad3dViewer = (node: LGraphNode) => {
materialMode, materialMode,
needApplyChanges, needApplyChanges,
isPreview, isPreview,
isStandaloneMode,
// Methods // Methods
initializeViewer, initializeViewer,
initializeStandaloneViewer,
exportModel, exportModel,
handleResize, handleResize,
handleMouseEnter, handleMouseEnter,