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:
Terry Jia
2026-01-17 22:32:32 -05:00
committed by GitHub
parent c9d74777ba
commit 82c3cd3cd2
5 changed files with 173 additions and 3 deletions

View File

@@ -511,6 +511,22 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
hasSkeleton.value = load3d?.hasSkeleton() ?? false
// Reset skeleton visibility when loading new model
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) => {
modelConfig.value.showSkeleton = value

View File

@@ -754,6 +754,60 @@ class Load3d {
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 {
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()

View File

@@ -1,9 +1,34 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
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(
imageData: string,
prefix: string,
@@ -122,6 +147,46 @@ class Load3dUtils {
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

View File

@@ -4,6 +4,7 @@ import Load3D from '@/components/load3d/Load3D.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
@@ -94,6 +95,17 @@ useExtensionService().registerExtension({
const config = new Load3DConfiguration(load3d, node.properties)
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)
}
})

View File

@@ -1,12 +1,35 @@
<template>
<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
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"
>
<i class="icon-[lucide--box] text-3xl text-muted-foreground" />
<span class="text-sm text-base-foreground">{{
$t('assetBrowser.media.threeDModelPlaceholder')
}}</span>
<span class="text-sm text-base-foreground">
{{ $t('assetBrowser.media.threeDModelPlaceholder') }}
</span>
</div>
</div>
</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>