mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-26 23:37:34 +00:00
Compare commits
1 Commits
refactor/a
...
cloud/v1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b501b18f |
@@ -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)
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
70
src/platform/assets/utils/assetPreviewUtil.ts
Normal file
70
src/platform/assets/utils/assetPreviewUtil.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user