mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
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:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user