Compare commits

...

1 Commits

Author SHA1 Message Date
Comfy Org PR Bot
08b501b18f [backport cloud/1.42] Feat/3d thumbnail inline rendering (#10047)
Backport of #9471 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10047-backport-cloud-1-42-Feat-3d-thumbnail-inline-rendering-3256d73d36508198bf09c25c9489b089)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-16 06:46:51 -07:00
9 changed files with 183 additions and 110 deletions

View File

@@ -35,7 +35,8 @@ vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
removeEventListener: vi.fn(),
getServerFeature: vi.fn(() => false)
}
}))

View File

@@ -5,6 +5,7 @@ import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
import type {
AnimationItem,
CameraConfig,
@@ -514,19 +515,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
if (load3d) {
if (load3d && api.getServerFeature('assets', false)) {
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'
)
if (typeof value === 'string' && value) {
const filename = value.trim().replace(/\s*\[output\]$/, '')
const modelName = Load3dUtils.splitFilePath(filename)[1]
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(modelName, blob))
.catch(() => {})
}
}
},

View File

@@ -20,6 +20,25 @@ import {
type UpDirection
} from './interfaces'
function positionThumbnailCamera(
camera: THREE.PerspectiveCamera,
model: THREE.Object3D
) {
const box = new THREE.Box3().setFromObject(model)
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
camera.position.set(
center.x + distance * 0.7,
center.y + distance * 0.5,
center.z + distance * 0.7
)
camera.lookAt(center)
camera.updateProjectionMatrix()
}
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
@@ -781,25 +800,18 @@ class Load3d {
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
positionThumbnailCamera(
this.cameraManager.perspectiveCamera,
this.modelManager.currentModel
)
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)
const box = new THREE.Box3().setFromObject(
this.modelManager.currentModel
)
this.controlsManager.controls.target.copy(
box.getCenter(new THREE.Vector3())
)
this.controlsManager.controls.update()
}

View File

@@ -1,34 +1,9 @@
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,
@@ -147,46 +122,6 @@ 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,16 +4,17 @@ 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'
import { api } from '@/scripts/api'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
type SaveMeshOutput = NodeOutputWith<{
'3d'?: ResultItem[]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
@@ -100,17 +101,20 @@ useExtensionService().registerExtension({
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)
if (api.getServerFeature('assets', false)) {
const filename = fileInfo.filename ?? ''
const onModelLoaded = () => {
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(filename, blob))
.catch(() => {})
}
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
}
}
})
}

View File

@@ -1,11 +1,10 @@
<template>
<div class="relative size-full overflow-hidden rounded-sm">
<div ref="containerRef" class="relative size-full overflow-hidden rounded-sm">
<img
v-if="!thumbnailError"
v-if="thumbnailSrc"
: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
@@ -20,16 +19,59 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import { onBeforeUnmount, ref, watch } from 'vue'
import { api } from '@/scripts/api'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { findServerPreviewUrl } from '../utils/assetPreviewUtil'
const { asset } = defineProps<{ asset: AssetMeta }>()
const thumbnailError = ref(false)
const containerRef = ref<HTMLElement>()
const thumbnailSrc = ref<string | null>(null)
const hasAttempted = ref(false)
const thumbnailSrc = computed(() => {
if (!asset?.src) return ''
return asset.src.replace(/([?&]filename=)([^&]*)/, '$1$2.png')
useIntersectionObserver(containerRef, ([entry]) => {
if (entry?.isIntersecting && !hasAttempted.value) {
hasAttempted.value = true
void loadThumbnail()
}
})
async function loadThumbnail() {
if (asset?.preview_id && asset?.preview_url) {
thumbnailSrc.value = asset.preview_url
return
}
if (!asset?.src) return
if (asset.name && api.getServerFeature('assets', false)) {
const serverPreviewUrl = await findServerPreviewUrl(asset.name)
if (serverPreviewUrl) {
thumbnailSrc.value = serverPreviewUrl
}
}
}
function revokeThumbnail() {
if (thumbnailSrc.value?.startsWith('blob:')) {
URL.revokeObjectURL(thumbnailSrc.value)
}
thumbnailSrc.value = null
}
watch(
() => asset?.src,
() => {
if (hasAttempted.value) {
hasAttempted.value = false
revokeThumbnail()
}
}
)
onBeforeUnmount(revokeThumbnail)
</script>

View File

@@ -150,6 +150,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
@@ -237,7 +238,12 @@ const adaptedAsset = computed(() => {
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.thumbnail_url || asset.preview_url || '',
src:
fileKind.value === '3D'
? getAssetUrl(asset)
: asset.thumbnail_url || asset.preview_url || '',
preview_url: asset.preview_url,
preview_id: asset.preview_id,
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,

View File

@@ -95,7 +95,7 @@ export type ModelFile = z.infer<typeof zModelFile>
/** Payload for updating an asset via PUT /assets/:id */
export type AssetUpdatePayload = Partial<
Pick<AssetItem, 'name' | 'tags' | 'user_metadata'>
Pick<AssetItem, 'name' | 'tags' | 'user_metadata' | 'preview_id'>
>
/** User-editable metadata fields for model assets */

View File

@@ -0,0 +1,70 @@
import { api } from '@/scripts/api'
import { assetService } from '../services/assetService'
interface AssetRecord {
id: string
name: string
preview_url?: string
preview_id?: string
}
async function fetchAssetsByName(name: string): Promise<AssetRecord[]> {
const params = new URLSearchParams({ name_contains: name })
const res = await api.fetchApi(`/assets?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.assets ?? []
}
export async function findServerPreviewUrl(
name: string
): Promise<string | null> {
try {
const assets = await fetchAssetsByName(name)
const modelAsset = assets.find((a) => a.name === name)
if (!modelAsset?.preview_id) return null
const previewAsset = assets.find((a) => a.id === modelAsset.preview_id)
if (!previewAsset?.preview_url) return null
return api.api_base + previewAsset.preview_url
} catch {
return null
}
}
export async function persistThumbnail(
modelName: string,
blob: Blob
): Promise<void> {
try {
const assets = await fetchAssetsByName(modelName)
const modelAsset = assets.find((a) => a.name === modelName)
if (!modelAsset || modelAsset.preview_id) return
const previewFilename = `${modelName}_preview.png`
const uploaded = await assetService.uploadAssetFromBase64({
data: await blobToDataUrl(blob),
name: previewFilename,
tags: ['output'],
user_metadata: { filename: previewFilename }
})
await assetService.updateAsset(modelAsset.id, {
preview_id: uploaded.id
})
} catch {
// Non-critical — client still shows the rendered thumbnail
}
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}