mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 18:22:40 +00:00
add thumbnail for 3d generation (#8129)
## Summary add thrumbnail for 3d genations, feature requested by @PabloWiedemann ## Screenshots https://github.com/user-attachments/assets/4fb9b88b-dd7b-4a69-a70c-e850472d3498 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8129-add-thumbnail-for-3d-generation-2eb6d73d365081f2a30bc698a4fde6e0) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -511,6 +511,22 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
|||||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||||
// Reset skeleton visibility when loading new model
|
// Reset skeleton visibility when loading new model
|
||||||
modelConfig.value.showSkeleton = false
|
modelConfig.value.showSkeleton = false
|
||||||
|
|
||||||
|
if (load3d) {
|
||||||
|
const node = nodeRef.value
|
||||||
|
|
||||||
|
const modelWidget = node?.widgets?.find(
|
||||||
|
(w) => w.name === 'model_file' || w.name === 'image'
|
||||||
|
)
|
||||||
|
const value = modelWidget?.value
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
void Load3dUtils.generateThumbnailIfNeeded(
|
||||||
|
load3d,
|
||||||
|
value,
|
||||||
|
isPreview.value ? 'output' : 'input'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
skeletonVisibilityChange: (value: boolean) => {
|
skeletonVisibilityChange: (value: boolean) => {
|
||||||
modelConfig.value.showSkeleton = value
|
modelConfig.value.showSkeleton = value
|
||||||
|
|||||||
@@ -754,6 +754,60 @@ class Load3d {
|
|||||||
this.forceRender()
|
this.forceRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async captureThumbnail(
|
||||||
|
width: number = 256,
|
||||||
|
height: number = 256
|
||||||
|
): Promise<string> {
|
||||||
|
if (!this.modelManager.currentModel) {
|
||||||
|
throw new Error('No model loaded for thumbnail capture')
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedState = this.cameraManager.getCameraState()
|
||||||
|
const savedCameraType = this.cameraManager.getCurrentCameraType()
|
||||||
|
const savedGridVisible = this.sceneManager.gridHelper.visible
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.sceneManager.gridHelper.visible = false
|
||||||
|
|
||||||
|
if (savedCameraType !== 'perspective') {
|
||||||
|
this.cameraManager.toggleCamera('perspective')
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = new THREE.Box3().setFromObject(this.modelManager.currentModel)
|
||||||
|
const size = box.getSize(new THREE.Vector3())
|
||||||
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
|
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z)
|
||||||
|
const distance = maxDim * 1.5
|
||||||
|
|
||||||
|
const cameraPosition = new THREE.Vector3(
|
||||||
|
center.x - distance * 0.8,
|
||||||
|
center.y + distance * 0.4,
|
||||||
|
center.z + distance * 0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
this.cameraManager.perspectiveCamera.position.copy(cameraPosition)
|
||||||
|
this.cameraManager.perspectiveCamera.lookAt(center)
|
||||||
|
this.cameraManager.perspectiveCamera.updateProjectionMatrix()
|
||||||
|
|
||||||
|
if (this.controlsManager.controls) {
|
||||||
|
this.controlsManager.controls.target.copy(center)
|
||||||
|
this.controlsManager.controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.sceneManager.captureScene(width, height)
|
||||||
|
return result.scene
|
||||||
|
} finally {
|
||||||
|
this.sceneManager.gridHelper.visible = savedGridVisible
|
||||||
|
|
||||||
|
if (savedCameraType !== 'perspective') {
|
||||||
|
this.cameraManager.toggleCamera(savedCameraType)
|
||||||
|
}
|
||||||
|
this.cameraManager.setCameraState(savedState)
|
||||||
|
this.controlsManager.controls?.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public remove(): void {
|
public remove(): void {
|
||||||
if (this.contextMenuAbortController) {
|
if (this.contextMenuAbortController) {
|
||||||
this.contextMenuAbortController.abort()
|
this.contextMenuAbortController.abort()
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
|
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
|
|
||||||
class Load3dUtils {
|
class Load3dUtils {
|
||||||
|
static async generateThumbnailIfNeeded(
|
||||||
|
load3d: Load3d,
|
||||||
|
modelPath: string,
|
||||||
|
folderType: 'input' | 'output'
|
||||||
|
): Promise<void> {
|
||||||
|
const [subfolder, filename] = this.splitFilePath(modelPath)
|
||||||
|
const thumbnailFilename = this.getThumbnailFilename(filename)
|
||||||
|
|
||||||
|
const exists = await this.fileExists(
|
||||||
|
subfolder,
|
||||||
|
thumbnailFilename,
|
||||||
|
folderType
|
||||||
|
)
|
||||||
|
if (exists) return
|
||||||
|
|
||||||
|
const imageData = await load3d.captureThumbnail(256, 256)
|
||||||
|
await this.uploadThumbnail(
|
||||||
|
imageData,
|
||||||
|
subfolder,
|
||||||
|
thumbnailFilename,
|
||||||
|
folderType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
static async uploadTempImage(
|
static async uploadTempImage(
|
||||||
imageData: string,
|
imageData: string,
|
||||||
prefix: string,
|
prefix: string,
|
||||||
@@ -122,6 +147,46 @@ class Load3dUtils {
|
|||||||
|
|
||||||
await Promise.all(uploadPromises)
|
await Promise.all(uploadPromises)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getThumbnailFilename(modelFilename: string): string {
|
||||||
|
return `${modelFilename}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fileExists(
|
||||||
|
subfolder: string,
|
||||||
|
filename: string,
|
||||||
|
type: string = 'input'
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const url = api.apiURL(this.getResourceURL(subfolder, filename, type))
|
||||||
|
const response = await fetch(url, { method: 'HEAD' })
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async uploadThumbnail(
|
||||||
|
imageData: string,
|
||||||
|
subfolder: string,
|
||||||
|
filename: string,
|
||||||
|
type: string = 'input'
|
||||||
|
): Promise<boolean> {
|
||||||
|
const blob = await fetch(imageData).then((r) => r.blob())
|
||||||
|
const file = new File([blob], filename, { type: 'image/png' })
|
||||||
|
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('image', file)
|
||||||
|
body.append('subfolder', subfolder)
|
||||||
|
body.append('type', type)
|
||||||
|
|
||||||
|
const resp = await api.fetchApi('/upload/image', {
|
||||||
|
method: 'POST',
|
||||||
|
body
|
||||||
|
})
|
||||||
|
|
||||||
|
return resp.status === 200
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Load3dUtils
|
export default Load3dUtils
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Load3D from '@/components/load3d/Load3D.vue'
|
|||||||
import { useLoad3d } from '@/composables/useLoad3d'
|
import { useLoad3d } from '@/composables/useLoad3d'
|
||||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||||
|
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||||
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
|
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
|
||||||
@@ -94,6 +95,17 @@ useExtensionService().registerExtension({
|
|||||||
const config = new Load3DConfiguration(load3d, node.properties)
|
const config = new Load3DConfiguration(load3d, node.properties)
|
||||||
|
|
||||||
const loadFolder = fileInfo.type as 'input' | 'output'
|
const loadFolder = fileInfo.type as 'input' | 'output'
|
||||||
|
|
||||||
|
const onModelLoaded = () => {
|
||||||
|
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
|
||||||
|
void Load3dUtils.generateThumbnailIfNeeded(
|
||||||
|
load3d,
|
||||||
|
filePath,
|
||||||
|
loadFolder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
|
||||||
|
|
||||||
config.configureForSaveMesh(loadFolder, filePath)
|
config.configureForSaveMesh(loadFolder, filePath)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative size-full overflow-hidden rounded">
|
<div class="relative size-full overflow-hidden rounded">
|
||||||
|
<img
|
||||||
|
v-if="!thumbnailError"
|
||||||
|
:src="thumbnailSrc"
|
||||||
|
:alt="asset?.name"
|
||||||
|
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||||
|
@error="thumbnailError = true"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
|
v-else
|
||||||
class="flex size-full flex-col items-center justify-center gap-2 bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
class="flex size-full flex-col items-center justify-center gap-2 bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--box] text-3xl text-muted-foreground" />
|
<i class="icon-[lucide--box] text-3xl text-muted-foreground" />
|
||||||
<span class="text-sm text-base-foreground">{{
|
<span class="text-sm text-base-foreground">
|
||||||
$t('assetBrowser.media.threeDModelPlaceholder')
|
{{ $t('assetBrowser.media.threeDModelPlaceholder') }}
|
||||||
}}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||||
|
|
||||||
|
const { asset } = defineProps<{ asset: AssetMeta }>()
|
||||||
|
|
||||||
|
const thumbnailError = ref(false)
|
||||||
|
|
||||||
|
const thumbnailSrc = computed(() => {
|
||||||
|
if (!asset?.src) return ''
|
||||||
|
return asset.src.replace(/([?&]filename=)([^&]*)/, '$1$2.png')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user