mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
6 Commits
v1.45.10
...
fix/disabl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99890d7d94 | ||
|
|
da27a78a94 | ||
|
|
9f0d4d9038 | ||
|
|
22270a0f81 | ||
|
|
30cf5754eb | ||
|
|
975b510486 |
@@ -1,9 +1,54 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import {
|
||||
preview3dPipelineTest as test,
|
||||
Preview3DPipelineContext
|
||||
} from '@e2e/fixtures/helpers/Preview3DPipelineFixture'
|
||||
|
||||
test.describe('Preview3D execution flow', { tag: ['@slow', '@node'] }, () => {
|
||||
test('Preview3D shows inline error and no toast when execution output has no model file', async ({
|
||||
preview3dPipeline: pipeline
|
||||
}) => {
|
||||
await pipeline.comfyPage.nextFrame()
|
||||
|
||||
await pipeline.comfyPage.page.evaluate((previewId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(previewId))
|
||||
node?.onExecuted?.({ result: [] })
|
||||
}, Preview3DPipelineContext.previewNodeId)
|
||||
|
||||
await pipeline.comfyPage.nextFrame()
|
||||
await expect(
|
||||
pipeline.comfyPage.page.getByTestId('load3d-error-overlay')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
pipeline.comfyPage.page.locator('.p-toast-message')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Preview3D shows inline error and no toast when model file cannot be loaded', async ({
|
||||
preview3dPipeline: pipeline
|
||||
}) => {
|
||||
await pipeline.comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = pipeline.comfyPage.page.waitForResponse((r) =>
|
||||
r.url().includes('nonexistent_preview_model.glb')
|
||||
)
|
||||
|
||||
await pipeline.comfyPage.page.evaluate((previewId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(previewId))
|
||||
node?.onExecuted?.({ result: ['nonexistent_preview_model.glb'] })
|
||||
}, Preview3DPipelineContext.previewNodeId)
|
||||
|
||||
await responsePromise
|
||||
await pipeline.comfyPage.nextFrame()
|
||||
await expect(
|
||||
pipeline.comfyPage.page.getByTestId('load3d-error-overlay')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
pipeline.comfyPage.page.locator('.p-toast-message')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Preview3D loads model from execution output', async ({
|
||||
preview3dPipeline: pipeline
|
||||
}) => {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:cleanup="cleanup"
|
||||
:loading="loading"
|
||||
:loading-message="loadingMessage"
|
||||
:load-error="loadError"
|
||||
:on-model-drop="isPreview ? undefined : handleModelDrop"
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
@@ -143,6 +144,7 @@ const {
|
||||
// other state
|
||||
isRecording,
|
||||
isPreview,
|
||||
loadError,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock('@/components/common/LoadingOverlay.vue', () => ({
|
||||
type RenderOpts = {
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
loadError?: string | null
|
||||
isPreview?: boolean
|
||||
onModelDrop?: (file: File) => void | Promise<void>
|
||||
initializeLoad3d?: (container: HTMLElement) => Promise<void>
|
||||
@@ -62,6 +63,7 @@ function renderComponent(opts: RenderOpts = {}) {
|
||||
cleanup,
|
||||
loading: opts.loading ?? false,
|
||||
loadingMessage: opts.loadingMessage ?? '',
|
||||
loadError: opts.loadError ?? null,
|
||||
onModelDrop: opts.onModelDrop,
|
||||
isPreview: opts.isPreview ?? false
|
||||
}
|
||||
@@ -150,4 +152,31 @@ describe('Load3DScene', () => {
|
||||
dragState.capturedOptions!.onModelDrop!(file)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
describe('error overlay', () => {
|
||||
it('shows the error overlay when loadError is set and not loading', () => {
|
||||
renderComponent({ loadError: 'No model received from connected node' })
|
||||
|
||||
expect(screen.getByTestId('load3d-error-overlay')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('No model received from connected node')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the error overlay when loadError is null', () => {
|
||||
renderComponent({ loadError: null })
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-error-overlay')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the error overlay while loading even if loadError is set', () => {
|
||||
renderComponent({ loadError: 'some error', loading: true })
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-error-overlay')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,21 @@
|
||||
@drop.prevent.stop="handleDrop"
|
||||
>
|
||||
<LoadingOverlay :loading="loading" :loading-message="loadingMessage" />
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="loadError && !loading"
|
||||
role="alert"
|
||||
class="pointer-events-none absolute inset-0 z-40 flex items-center justify-center"
|
||||
data-testid="load3d-error-overlay"
|
||||
>
|
||||
<div
|
||||
class="mx-4 flex items-center gap-2 rounded-lg bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<i class="pi pi-exclamation-triangle shrink-0" />
|
||||
{{ loadError }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div
|
||||
v-if="!isPreview && isDragging"
|
||||
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@@ -40,6 +55,7 @@ const props = defineProps<{
|
||||
cleanup: () => void
|
||||
loading: boolean
|
||||
loadingMessage: string
|
||||
loadError?: string | null
|
||||
onModelDrop?: (file: File) => void | Promise<void>
|
||||
isPreview: boolean
|
||||
}>()
|
||||
|
||||
@@ -755,7 +755,8 @@ describe('useLoad3d', () => {
|
||||
'recordingStatusChange',
|
||||
'animationListChange',
|
||||
'animationProgressChange',
|
||||
'cameraChanged'
|
||||
'cameraChanged',
|
||||
'modelLoadError'
|
||||
]
|
||||
|
||||
expectedEvents.forEach((event) => {
|
||||
@@ -815,6 +816,34 @@ describe('useLoad3d', () => {
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
})
|
||||
|
||||
it('modelLoadError event sets loadError and modelLoadingStart clears it', async () => {
|
||||
let modelLoadErrorHandler: ((msg: string) => void) | undefined
|
||||
let modelLoadingStartHandler: (() => void) | undefined
|
||||
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'modelLoadError') {
|
||||
modelLoadErrorHandler = handler as (msg: string) => void
|
||||
} else if (event === 'modelLoadingStart') {
|
||||
modelLoadingStartHandler = handler as () => void
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(composable.loadError.value).toBeNull()
|
||||
|
||||
modelLoadErrorHandler?.('No model received from connected node')
|
||||
expect(composable.loadError.value).toBe(
|
||||
'No model received from connected node'
|
||||
)
|
||||
|
||||
modelLoadingStartHandler?.()
|
||||
expect(composable.loadError.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle recordingStatusChange event', async () => {
|
||||
let recordingStatusHandler: ((status: boolean) => void) | undefined
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const animationDuration = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
const loadError = ref<string | null>(null)
|
||||
const isPreview = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
@@ -132,6 +133,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
height: heightWidget.value as number
|
||||
})
|
||||
: undefined,
|
||||
suppressLoadErrors: isPreview.value,
|
||||
onContextMenu: (event) => {
|
||||
const menuOptions = app.canvas.getNodeMenuOptions(node)
|
||||
new LiteGraph.ContextMenu(menuOptions, {
|
||||
@@ -774,6 +776,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
modelLoadingStart: () => {
|
||||
loadingMessage.value = t('load3d.loadingModel')
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
if (!isFirstModelLoad) {
|
||||
modelConfig.value = {
|
||||
upDirection: 'original',
|
||||
@@ -882,6 +885,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
modelConfig.value.gizmo.enabled = data.enabled
|
||||
modelConfig.value.gizmo.mode = data.mode
|
||||
}
|
||||
},
|
||||
modelLoadError: (message: string) => {
|
||||
loadError.value = message
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -942,6 +948,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
lightConfig,
|
||||
isRecording,
|
||||
isPreview,
|
||||
loadError,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
|
||||
@@ -510,9 +510,11 @@ useExtensionService().registerExtension({
|
||||
const filePath = result?.[0]
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
console.error(t('toastMessages.unableToGetModelFilePath'))
|
||||
load3d.eventManager.emitEvent(
|
||||
'modelLoadError',
|
||||
t('load3d.noModelReceived')
|
||||
)
|
||||
}
|
||||
|
||||
const cameraState = result?.[1]
|
||||
|
||||
@@ -333,18 +333,49 @@ describe('LoaderManager', () => {
|
||||
expect(modelManager.originalFileName).toBe('model')
|
||||
})
|
||||
|
||||
it('alerts when the file extension cannot be determined', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
it('alerts and emits modelLoadingEnd when the file extension cannot be determined', async () => {
|
||||
const { lm, modelManager, eventManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?other=1')
|
||||
|
||||
expect(addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.couldNotDetermineFileType'
|
||||
)
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingEnd',
|
||||
null
|
||||
)
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
expect(meshLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits modelLoadError and modelLoadingEnd (no toast) when suppressErrors is true and extension cannot be determined', async () => {
|
||||
const modelManager = makeModelManagerStub()
|
||||
const eventManager = makeEventManagerStub()
|
||||
const lm = new LoaderManager(
|
||||
modelManager as unknown as ConstructorParameters<
|
||||
typeof LoaderManager
|
||||
>[0],
|
||||
eventManager,
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
|
||||
await lm.loadModel('api/view?other=1')
|
||||
|
||||
expect(addAlert).not.toHaveBeenCalled()
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadError',
|
||||
'toastMessages.couldNotDetermineFileType'
|
||||
)
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingEnd',
|
||||
null
|
||||
)
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes setupModel the object returned by the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const loaded = new THREE.Object3D()
|
||||
@@ -521,6 +552,34 @@ describe('LoaderManager', () => {
|
||||
expect(capturedCtx!.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('emits modelLoadError and skips toast when suppressErrors is true and the adapter throws', async () => {
|
||||
const modelManager = makeModelManagerStub()
|
||||
const eventManager = makeEventManagerStub()
|
||||
const lm = new LoaderManager(
|
||||
modelManager as unknown as ConstructorParameters<
|
||||
typeof LoaderManager
|
||||
>[0],
|
||||
eventManager,
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
meshLoad.mockRejectedValueOnce(new Error('boom'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(addAlert).not.toHaveBeenCalled()
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadError',
|
||||
expect.any(String)
|
||||
)
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingEnd',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses alerts and modelLoadingEnd when a stale load throws', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
|
||||
|
||||
@@ -31,18 +31,21 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
private readonly eventManager: EventManagerInterface
|
||||
private readonly adapters: ModelAdapter[]
|
||||
private readonly adapterRef: AdapterRef
|
||||
private readonly suppressErrors: boolean
|
||||
private currentLoadId: number = 0
|
||||
|
||||
constructor(
|
||||
modelManager: ModelManagerInterface,
|
||||
eventManager: EventManagerInterface,
|
||||
adapters?: readonly ModelAdapter[],
|
||||
adapterRef?: AdapterRef
|
||||
adapterRef?: AdapterRef,
|
||||
suppressErrors: boolean = false
|
||||
) {
|
||||
this.modelManager = modelManager
|
||||
this.eventManager = eventManager
|
||||
this.adapters = adapters ? [...adapters] : defaultAdapters()
|
||||
this.adapterRef = adapterRef ?? createAdapterRef()
|
||||
this.suppressErrors = suppressErrors
|
||||
}
|
||||
|
||||
getCurrentAdapter(): ModelAdapter | null {
|
||||
@@ -79,7 +82,13 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
|
||||
if (!fileExtension) {
|
||||
useToastStore().addAlert(t('toastMessages.couldNotDetermineFileType'))
|
||||
const message = t('toastMessages.couldNotDetermineFileType')
|
||||
if (this.suppressErrors) {
|
||||
this.eventManager.emitEvent('modelLoadError', message)
|
||||
} else {
|
||||
useToastStore().addAlert(message)
|
||||
}
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,7 +114,14 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
if (loadId === this.currentLoadId) {
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
console.error('Error loading model:', error)
|
||||
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
|
||||
if (this.suppressErrors) {
|
||||
this.eventManager.emitEvent(
|
||||
'modelLoadError',
|
||||
t('toastMessages.errorLoadingModel')
|
||||
)
|
||||
} else {
|
||||
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer {
|
||||
return renderer
|
||||
}
|
||||
|
||||
function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
function buildLoad3dDeps(
|
||||
container: Element | HTMLElement,
|
||||
options?: Load3DOptions
|
||||
): Load3dDeps {
|
||||
const renderer = createRenderer(container)
|
||||
const eventManager = new EventManager()
|
||||
// Shared mutable handle: LoaderManager writes the active adapter on each
|
||||
@@ -94,7 +97,8 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
modelManager,
|
||||
eventManager,
|
||||
undefined,
|
||||
adapterRef
|
||||
adapterRef,
|
||||
options?.suppressLoadErrors ?? false
|
||||
)
|
||||
const recordingManager = new RecordingManager(
|
||||
sceneManager.scene,
|
||||
@@ -140,5 +144,5 @@ export function createLoad3d(
|
||||
container: Element | HTMLElement,
|
||||
options?: Load3DOptions
|
||||
): Load3d {
|
||||
return new Load3d(container, buildLoad3dDeps(container), options)
|
||||
return new Load3d(container, buildLoad3dDeps(container, options), options)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ export interface Load3DOptions {
|
||||
|
||||
// Optional context menu callback
|
||||
onContextMenu?: (event: MouseEvent) => void
|
||||
|
||||
// Suppress model-load error toasts (e.g. for preview nodes that don't own their model file)
|
||||
suppressLoadErrors?: boolean
|
||||
}
|
||||
|
||||
export interface CaptureResult {
|
||||
|
||||
@@ -1951,6 +1951,7 @@
|
||||
"tiledMode": "Tiled",
|
||||
"panoramaMode": "Panorama",
|
||||
"loadingModel": "Loading 3D Model...",
|
||||
"noModelReceived": "No model received from connected node",
|
||||
"upDirection": "Up Direction",
|
||||
"materialMode": "Material Mode",
|
||||
"showSkeleton": "Show Skeleton",
|
||||
|
||||
Reference in New Issue
Block a user