mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
Compare commits
5 Commits
glary/raf-
...
glary/asse
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293fde098d | ||
|
|
6f3bacf483 | ||
|
|
d955625c20 | ||
|
|
861d737041 | ||
|
|
7160a9ee3f |
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-neutral-700 border-solid border-y"
|
||||
class="border-y border-solid border-neutral-700"
|
||||
:class="{
|
||||
'opacity-50': runner.resolved,
|
||||
'opacity-75': isLoading && runner.resolved
|
||||
}"
|
||||
>
|
||||
<td class="text-center w-16">
|
||||
<td class="w-16 text-center">
|
||||
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
|
||||
</td>
|
||||
<td>
|
||||
@@ -14,7 +14,7 @@
|
||||
{{ task.name }}
|
||||
</p>
|
||||
<Button
|
||||
class="inline-block mx-2"
|
||||
class="mx-2 inline-block"
|
||||
type="button"
|
||||
:icon="PrimeIcons.INFO_CIRCLE"
|
||||
severity="secondary"
|
||||
@@ -22,11 +22,11 @@
|
||||
@click="toggle"
|
||||
/>
|
||||
|
||||
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
|
||||
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
|
||||
<span class="whitespace-pre-line">{{ task.description }}</span>
|
||||
</Popover>
|
||||
</td>
|
||||
<td class="text-right px-4">
|
||||
<td class="px-4 text-right">
|
||||
<Button
|
||||
:icon="task.button?.icon"
|
||||
:label="task.button?.text"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
|
||||
@@ -16,7 +16,9 @@ const {
|
||||
resetRoot,
|
||||
mockAddNodeOnGraph,
|
||||
mockGetNodeProvider,
|
||||
mockToggleNodeOnEvent
|
||||
mockToggleNodeOnEvent,
|
||||
mockRefreshModelFolder,
|
||||
downloadStoreState
|
||||
} = vi.hoisted(() => {
|
||||
let capturedRoot: TreeExplorerNode<unknown> | null = null
|
||||
return {
|
||||
@@ -29,7 +31,9 @@ const {
|
||||
},
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn(),
|
||||
mockToggleNodeOnEvent: vi.fn()
|
||||
mockToggleNodeOnEvent: vi.fn(),
|
||||
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
|
||||
downloadStoreState: { setLastCompleted: (_: unknown) => {} }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -59,10 +63,31 @@ vi.mock('@/stores/modelStore', () => ({
|
||||
modelFolders: [],
|
||||
models: [mockModel],
|
||||
loadModels: vi.fn().mockResolvedValue([]),
|
||||
loadModelFolders: vi.fn().mockResolvedValue([])
|
||||
loadModelFolders: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
refreshModelFolder: mockRefreshModelFolder
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetDownloadStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const lastCompletedDownload = ref<{
|
||||
taskId: string
|
||||
modelType: string
|
||||
timestamp: number
|
||||
} | null>(null)
|
||||
downloadStoreState.setLastCompleted = (value) => {
|
||||
lastCompletedDownload.value = value as typeof lastCompletedDownload.value
|
||||
}
|
||||
return {
|
||||
useAssetDownloadStore: () => ({
|
||||
get lastCompletedDownload() {
|
||||
return lastCompletedDownload.value
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn((key: string) => {
|
||||
@@ -131,6 +156,7 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRoot()
|
||||
downloadStoreState.setLastCompleted(null)
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
@@ -188,4 +214,27 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
|
||||
expect(mockToggleNodeOnEvent).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes the affected folder when an asset download completes', async () => {
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
|
||||
|
||||
downloadStoreState.setLastCompleted({
|
||||
taskId: 'task-1',
|
||||
modelType: 'checkpoints',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(mockRefreshModelFolder).toHaveBeenCalledWith('checkpoints')
|
||||
})
|
||||
|
||||
it('does not refresh when no download has completed', async () => {
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.refresh')"
|
||||
@click="modelStore.loadModelFolders"
|
||||
@click="modelStore.refresh"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] size-4" />
|
||||
</Button>
|
||||
@@ -66,6 +66,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
import { ResourceState, useModelStore } from '@/stores/modelStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
@@ -76,6 +77,7 @@ import { buildTree } from '@/utils/treeUtil'
|
||||
const modelStore = useModelStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const searchBoxRef = ref()
|
||||
const searchQuery = ref<string>('')
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
@@ -189,6 +191,14 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => assetDownloadStore.lastCompletedDownload,
|
||||
(completed) => {
|
||||
if (!completed) return
|
||||
void modelStore.refreshModelFolder(completed.modelType)
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
searchBoxRef.value?.focus()
|
||||
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
|
||||
|
||||
@@ -13,25 +13,18 @@ vi.mock('@/i18n', () => ({
|
||||
const executionStore = reactive<{
|
||||
isIdle: boolean
|
||||
executionProgress: number
|
||||
executingNode: unknown
|
||||
executingNode: null | {
|
||||
title?: string
|
||||
type?: string
|
||||
}
|
||||
executingNodeProgress: number
|
||||
nodeProgressStates: Record<string, unknown>
|
||||
activeJob: {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
nodes: { id: number; type: string }[]
|
||||
}
|
||||
}
|
||||
}
|
||||
} | null
|
||||
}>({
|
||||
isIdle: true,
|
||||
executionProgress: 0,
|
||||
executingNode: null,
|
||||
executingNodeProgress: 0,
|
||||
nodeProgressStates: {},
|
||||
activeJob: null
|
||||
nodeProgressStates: {}
|
||||
})
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStore
|
||||
@@ -75,7 +68,6 @@ describe('useBrowserTabTitle', () => {
|
||||
executionStore.executingNode = null
|
||||
executionStore.executingNodeProgress = 0
|
||||
executionStore.nodeProgressStates = {}
|
||||
executionStore.activeJob = null
|
||||
|
||||
// reset setting and workflow stores
|
||||
vi.mocked(settingStore.get).mockReturnValue('Enabled')
|
||||
@@ -185,18 +177,12 @@ describe('useBrowserTabTitle', () => {
|
||||
it('shows node execution title when executing a node using nodeProgressStates', async () => {
|
||||
executionStore.isIdle = false
|
||||
executionStore.executionProgress = 0.4
|
||||
executionStore.executingNode = {
|
||||
type: 'Foo'
|
||||
}
|
||||
executionStore.nodeProgressStates = {
|
||||
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
|
||||
}
|
||||
executionStore.activeJob = {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
nodes: [{ id: 1, type: 'Foo' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
|
||||
@@ -74,14 +74,14 @@ export const useBrowserTabTitle = () => {
|
||||
}
|
||||
|
||||
// If only one node is running
|
||||
const [nodeId, state] = runningNodes[0]
|
||||
const [, state] = runningNodes[0]
|
||||
const progress = Math.round((state.value / state.max) * 100)
|
||||
const nodeType =
|
||||
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
|
||||
(n) => String(n.id) === nodeId
|
||||
)?.type || 'Node'
|
||||
const nodeLabel =
|
||||
executionStore.executingNode?.type?.trim() ||
|
||||
executionStore.executingNode?.title?.trim() ||
|
||||
'Node'
|
||||
|
||||
return `${executionText.value}[${progress}%] ${nodeType}`
|
||||
return `${executionText.value}[${progress}%] ${nodeLabel}`
|
||||
})
|
||||
|
||||
const workflowTitle = computed(
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type * as ModelStoreModule from '@/stores/modelStore'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Mock vue-i18n for useExternalLink
|
||||
@@ -67,6 +68,15 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockModelStoreRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@/stores/modelStore', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ModelStoreModule>()
|
||||
return {
|
||||
...actual,
|
||||
useModelStore: () => ({ refresh: mockModelStoreRefresh })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore')
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
@@ -558,10 +568,11 @@ describe('useCoreCommands', () => {
|
||||
expect(app.openClipspace).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Comfy.RefreshNodeDefinitions awaits app.refreshComboInNodes', async () => {
|
||||
it('Comfy.RefreshNodeDefinitions refreshes combos and the model library', async () => {
|
||||
await findCmd('Comfy.RefreshNodeDefinitions').function()
|
||||
|
||||
expect(app.refreshComboInNodes).toHaveBeenCalled()
|
||||
expect(mockModelStoreRefresh).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import {
|
||||
useQueueSettingsStore,
|
||||
@@ -82,6 +83,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const modelStore = useModelStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -306,7 +308,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Refresh Node Definitions',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await app.refreshComboInNodes()
|
||||
await Promise.all([app.refreshComboInNodes(), modelStore.refresh()])
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1559,4 +1559,137 @@ describe('useLoad3d', () => {
|
||||
expect(persistThumbnail).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForLoad3d / onLoad3dReady', () => {
|
||||
it('fires waitForLoad3d callback when load3d initializes, then drops it', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const cb = vi.fn()
|
||||
composable.waitForLoad3d(cb)
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
|
||||
composable.cleanup()
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fires onLoad3dReady callback on every (re-)initialization', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const cb = vi.fn()
|
||||
composable.onLoad3dReady(cb)
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
|
||||
composable.cleanup()
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
expect(cb).toHaveBeenCalledTimes(2)
|
||||
|
||||
composable.cleanup()
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
expect(cb).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('fires onLoad3dReady synchronously when load3d already exists', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
const cb = vi.fn()
|
||||
composable.onLoad3dReady(cb)
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears persistent callbacks when the node is removed', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const cb = vi.fn()
|
||||
composable.onLoad3dReady(cb)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
composable.cleanup()
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('isolates a throwing callback so subsequent callbacks and event wiring still run', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const throwing = vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const after = vi.fn()
|
||||
composable.waitForLoad3d(throwing)
|
||||
composable.onLoad3dReady(after)
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(throwing).toHaveBeenCalledTimes(1)
|
||||
expect(after).toHaveBeenCalledTimes(1)
|
||||
expect(mockLoad3d.addEventListener).toHaveBeenCalled()
|
||||
expect(mockToastStore.addAlert).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Load3d ready callback failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('isolates a throwing callback in the synchronous already-mounted path', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
const throwing = vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
expect(() => composable.waitForLoad3d(throwing)).not.toThrow()
|
||||
expect(() => composable.onLoad3dReady(throwing)).not.toThrow()
|
||||
expect(throwing).toHaveBeenCalledTimes(2)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('cleans up callback maps when the node is removed before initializeLoad3d runs', async () => {
|
||||
const leakedWait = vi.fn()
|
||||
const leakedReady = vi.fn()
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
composable.waitForLoad3d(leakedWait)
|
||||
composable.onLoad3dReady(leakedReady)
|
||||
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(leakedWait).not.toHaveBeenCalled()
|
||||
expect(leakedReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('chains the onRemoved cleanup only once per node', () => {
|
||||
const originalOnRemoved = vi.fn()
|
||||
mockNode.onRemoved = originalOnRemoved
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
composable.waitForLoad3d(vi.fn())
|
||||
composable.onLoad3dReady(vi.fn())
|
||||
composable.onLoad3dReady(vi.fn())
|
||||
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
expect(originalOnRemoved).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,30 @@ import { useLoad3dService } from '@/services/load3dService'
|
||||
type Load3dReadyCallback = (load3d: Load3d) => void
|
||||
export const nodeToLoad3dMap = new Map<LGraphNode, Load3d>()
|
||||
const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
|
||||
const persistentReadyCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
|
||||
|
||||
const nodesWithCleanup = new WeakSet<LGraphNode>()
|
||||
|
||||
const ensureNodeCleanupChained = (node: LGraphNode): void => {
|
||||
if (nodesWithCleanup.has(node)) return
|
||||
nodesWithCleanup.add(node)
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
pendingCallbacks.delete(node)
|
||||
persistentReadyCallbacks.delete(node)
|
||||
})
|
||||
}
|
||||
|
||||
const invokeReadyCallback = (
|
||||
callback: Load3dReadyCallback,
|
||||
instance: Load3d
|
||||
): void => {
|
||||
try {
|
||||
callback(instance)
|
||||
} catch (error) {
|
||||
console.error('Load3d ready callback failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const nodeRef = toRef(nodeOrRef)
|
||||
@@ -177,10 +201,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
)
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
pendingCallbacks.delete(node)
|
||||
})
|
||||
ensureNodeCleanupChained(node)
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
@@ -188,13 +209,18 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
if (callbacks && load3d) {
|
||||
callbacks.forEach((callback) => {
|
||||
if (load3d) {
|
||||
callback(load3d)
|
||||
}
|
||||
if (load3d) invokeReadyCallback(callback, load3d)
|
||||
})
|
||||
pendingCallbacks.delete(node)
|
||||
}
|
||||
|
||||
const persistent = persistentReadyCallbacks.get(node)
|
||||
if (persistent && load3d) {
|
||||
persistent.forEach((callback) => {
|
||||
if (load3d) invokeReadyCallback(callback, load3d)
|
||||
})
|
||||
}
|
||||
|
||||
handleEvents('add')
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d:', error)
|
||||
@@ -351,8 +377,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const existingInstance = nodeToLoad3dMap.get(node)
|
||||
|
||||
if (existingInstance) {
|
||||
callback(existingInstance)
|
||||
|
||||
invokeReadyCallback(callback, existingInstance)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -361,6 +386,23 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
|
||||
pendingCallbacks.get(node)!.push(callback)
|
||||
ensureNodeCleanupChained(node)
|
||||
}
|
||||
|
||||
const onLoad3dReady = (callback: Load3dReadyCallback) => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
if (!rawNode) return
|
||||
|
||||
const node = rawNode as LGraphNode
|
||||
|
||||
if (!persistentReadyCallbacks.has(node)) {
|
||||
persistentReadyCallbacks.set(node, [])
|
||||
}
|
||||
persistentReadyCallbacks.get(node)!.push(callback)
|
||||
ensureNodeCleanupChained(node)
|
||||
|
||||
const existingInstance = nodeToLoad3dMap.get(node)
|
||||
if (existingInstance) invokeReadyCallback(callback, existingInstance)
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -979,6 +1021,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
// Methods
|
||||
initializeLoad3d,
|
||||
waitForLoad3d,
|
||||
onLoad3dReady,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleStartRecording,
|
||||
|
||||
@@ -7,12 +7,14 @@ import type { ComfyExtension } from '@/types/comfy'
|
||||
const {
|
||||
registerExtensionMock,
|
||||
waitForLoad3dMock,
|
||||
onLoad3dReadyMock,
|
||||
configureMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
onLoad3dReadyMock: vi.fn(),
|
||||
configureMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn()
|
||||
@@ -30,7 +32,10 @@ vi.mock('@/services/load3dService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock }),
|
||||
useLoad3d: () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap: new Map()
|
||||
}))
|
||||
|
||||
@@ -187,6 +192,9 @@ function setupBaseMocks() {
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
onLoad3dReadyMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
}
|
||||
|
||||
describe('load3d module registration', () => {
|
||||
@@ -271,6 +279,30 @@ describe('Comfy.Preview3D.nodeCreated', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('registers a persistent onLoad3dReady hook so subgraph re-entry rehydrates the model', async () => {
|
||||
const onReadyCallbacks: Array<(load3d: FakeLoad3d) => void> = []
|
||||
onLoad3dReadyMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
onReadyCallbacks.push(cb)
|
||||
})
|
||||
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode({
|
||||
properties: { 'Last Time Model File': 'persisted/model.glb' }
|
||||
})
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
expect(onReadyCallbacks).toHaveLength(1)
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
|
||||
// First mount.
|
||||
onReadyCallbacks[0](makeLoad3dMock())
|
||||
expect(configureMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Subgraph exit + re-entry: same callback fires again with a fresh load3d.
|
||||
onReadyCallbacks[0](makeLoad3dMock())
|
||||
expect(configureMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('persists Last Time Model File and normalizes backslashes after onExecuted', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
@@ -337,29 +337,34 @@ useExtensionService().registerExtension({
|
||||
|
||||
await nextTick()
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
const width = node.widgets?.find((w) => w.name === 'width')
|
||||
const height = node.widgets?.find((w) => w.name === 'height')
|
||||
if (!modelWidget || !width || !height) return
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configure({
|
||||
loadFolder: 'input',
|
||||
modelWidget,
|
||||
cameraState,
|
||||
width,
|
||||
height
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d(() => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
const width = node.widgets?.find((w) => w.name === 'width')
|
||||
const height = node.widgets?.find((w) => w.name === 'height')
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
|
||||
if (modelWidget && width && height && sceneWidget) {
|
||||
const settings = {
|
||||
loadFolder: 'input',
|
||||
modelWidget: modelWidget,
|
||||
cameraState: cameraState,
|
||||
width: width,
|
||||
height: height
|
||||
}
|
||||
config.configure(settings)
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
@@ -477,32 +482,35 @@ useExtensionService().registerExtension({
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (!modelWidget) return
|
||||
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
modelWidget.value = lastTimeModelFile
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configure({
|
||||
loadFolder: 'output',
|
||||
modelWidget,
|
||||
cameraState,
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
|
||||
if (modelWidget) {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
|
||||
if (lastTimeModelFile) {
|
||||
modelWidget.value = lastTimeModelFile
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
|
||||
const settings = {
|
||||
loadFolder: 'output',
|
||||
modelWidget: modelWidget,
|
||||
cameraState: cameraState,
|
||||
silentOnNotFound: true
|
||||
}
|
||||
|
||||
config.configure(settings)
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Load3dPreviewOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
|
||||
@@ -3,12 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const { registerExtensionMock, waitForLoad3dMock, configureForSaveMeshMock } =
|
||||
vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
configureForSaveMeshMock: vi.fn()
|
||||
}))
|
||||
const {
|
||||
registerExtensionMock,
|
||||
waitForLoad3dMock,
|
||||
onLoad3dReadyMock,
|
||||
configureForSaveMeshMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
onLoad3dReadyMock: vi.fn(),
|
||||
configureForSaveMeshMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension: registerExtensionMock })
|
||||
@@ -19,7 +24,10 @@ vi.mock('@/services/load3dService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock })
|
||||
useLoad3d: () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
@@ -74,11 +82,15 @@ function makeNode(
|
||||
describe('saveMesh', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const fakeLoad3d = () => ({
|
||||
whenLoadIdle: () => Promise.resolve(),
|
||||
captureThumbnail: vi.fn()
|
||||
})
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
|
||||
cb({
|
||||
whenLoadIdle: () => Promise.resolve(),
|
||||
captureThumbnail: vi.fn()
|
||||
})
|
||||
cb(fakeLoad3d())
|
||||
})
|
||||
onLoad3dReadyMock.mockImplementation((cb: (load3d: unknown) => void) => {
|
||||
cb(fakeLoad3d())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,6 +142,33 @@ describe('saveMesh', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('registers a persistent onLoad3dReady hook so subgraph re-entry rehydrates the model', async () => {
|
||||
const onReadyCallbacks: Array<(load3d: unknown) => void> = []
|
||||
onLoad3dReadyMock.mockImplementation((cb: (load3d: unknown) => void) => {
|
||||
onReadyCallbacks.push(cb)
|
||||
})
|
||||
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode({
|
||||
properties: {
|
||||
'Last Time Model File': 'sub/model.glb',
|
||||
'Last Time Model Folder': 'output'
|
||||
}
|
||||
})
|
||||
|
||||
await ext.nodeCreated(node)
|
||||
expect(onReadyCallbacks).toHaveLength(1)
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
|
||||
const fakeLoad3d = { whenLoadIdle: () => Promise.resolve() }
|
||||
|
||||
onReadyCallbacks[0](fakeLoad3d)
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
onReadyCallbacks[0]({ whenLoadIdle: () => Promise.resolve() })
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('defaults the load folder to output when only the file path is persisted', async () => {
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode({
|
||||
|
||||
@@ -81,7 +81,7 @@ useExtensionService().registerExtension({
|
||||
|
||||
await nextTick()
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
if (!load3d) return
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
@@ -96,15 +96,14 @@ useExtensionService().registerExtension({
|
||||
| 'output'
|
||||
| undefined) ?? 'output'
|
||||
|
||||
if (lastTimeModelFile) {
|
||||
modelWidget.value = lastTimeModelFile
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
modelWidget.value = lastTimeModelFile
|
||||
|
||||
config.configureForSaveMesh(lastTimeModelFolder, lastTimeModelFile, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
}
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(lastTimeModelFolder, lastTimeModelFile, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
@@ -3097,6 +3097,7 @@
|
||||
}
|
||||
},
|
||||
"mediaAsset": {
|
||||
"previewNotAvailable": "Preview not available",
|
||||
"deleteAssetTitle": "Delete this asset?",
|
||||
"deleteAssetDescription": "This asset will be permanently removed.",
|
||||
"deleteSelectedTitle": "Delete selected assets?",
|
||||
|
||||
@@ -161,6 +161,7 @@ import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
|
||||
import { renditionFor } from '../utils/assetRenditions'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
|
||||
@@ -224,7 +225,15 @@ const fileKind = computed((): MediaKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
})
|
||||
|
||||
// Route by MIME family first so non-renderable image originals (e.g. EXR
|
||||
// with an AVIF preview) still flow through MediaImageTop and pick up its
|
||||
// rendition + placeholder logic instead of falling into the generic
|
||||
// MediaOtherTop bucket based on file extension alone.
|
||||
const previewKind = computed((): PreviewKind => {
|
||||
const mimeFamily = asset?.mime_type?.toLowerCase().split('/')[0]
|
||||
if (mimeFamily === 'image') return 'image'
|
||||
if (mimeFamily === 'video') return 'video'
|
||||
if (mimeFamily === 'audio') return 'audio'
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
})
|
||||
|
||||
@@ -235,18 +244,24 @@ const fileName = computed(() => {
|
||||
return getFilenameDetails(asset ? getAssetDisplayName(asset) : '').filename
|
||||
})
|
||||
|
||||
// Adapt AssetItem to legacy AssetMeta format for existing components
|
||||
// Adapt AssetItem to legacy AssetMeta format for existing components.
|
||||
// `src` resolution differs by kind: 3D viewers need the canonical model URL
|
||||
// (.glb/.obj); other previews want the thumbnail/preview rendition chain
|
||||
// from renditionFor() so non-renderable originals (e.g. EXR) fall through
|
||||
// to the icon placeholder in MediaImageTop instead of a broken <img>.
|
||||
const adaptedAsset = computed(() => {
|
||||
if (!asset) return undefined
|
||||
const src =
|
||||
fileKind.value === '3D'
|
||||
? getAssetUrl(asset)
|
||||
: (renditionFor(asset, 'grid') ?? '')
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
display_name: asset.display_name,
|
||||
kind: fileKind.value,
|
||||
src:
|
||||
fileKind.value === '3D'
|
||||
? getAssetUrl(asset)
|
||||
: asset.thumbnail_url || asset.preview_url || '',
|
||||
src,
|
||||
mime_type: asset.mime_type,
|
||||
preview_url: asset.preview_url,
|
||||
preview_id: asset.preview_id,
|
||||
size: asset.size,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@dblclick="emit('view')"
|
||||
>
|
||||
<img
|
||||
v-if="!error"
|
||||
v-if="showImage"
|
||||
:src="asset.src"
|
||||
:alt="getAssetDisplayName(asset)"
|
||||
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
@@ -12,18 +12,28 @@
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center bg-modal-card-placeholder-background"
|
||||
class="flex size-full flex-col items-center justify-center gap-2 bg-modal-card-placeholder-background p-3 text-center text-muted-foreground"
|
||||
data-testid="media-image-placeholder"
|
||||
>
|
||||
<i class="pi pi-image text-3xl text-muted-foreground" />
|
||||
<i :class="cn('size-8', placeholderIcon)" />
|
||||
<span class="line-clamp-2 max-w-full text-xs break-all">
|
||||
{{ getAssetDisplayName(asset) }}
|
||||
</span>
|
||||
<span class="text-2xs text-muted-foreground/70">
|
||||
{{ $t('mediaAsset.previewNotAvailable') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useImage, whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import { iconForMimeType } from '../utils/mediaIconUtil'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
@@ -39,6 +49,10 @@ const { state, error, isReady } = useImage({
|
||||
alt: getAssetDisplayName(asset)
|
||||
})
|
||||
|
||||
const showImage = computed(() => !error.value && !!asset.src)
|
||||
|
||||
const placeholderIcon = computed(() => iconForMimeType(asset.mime_type))
|
||||
|
||||
whenever(
|
||||
() =>
|
||||
isReady.value && state.value?.naturalWidth && state.value?.naturalHeight,
|
||||
|
||||
@@ -487,7 +487,8 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
|
||||
describe('downloadAssets', () => {
|
||||
it('downloads the injected media asset when called without explicit assets', () => {
|
||||
it('downloads the canonical asset (not the preview rendition) when called without explicit assets', () => {
|
||||
mockGetAssetType.mockReturnValue('input')
|
||||
const mediaAsset = createMockMediaAsset({
|
||||
id: 'context-asset',
|
||||
name: 'context-name.png',
|
||||
@@ -499,10 +500,11 @@ describe('useMediaAssetActions', () => {
|
||||
actions.downloadAssets()
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledOnce()
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/context-preview.png',
|
||||
'Context image.png'
|
||||
)
|
||||
const [downloadUrl, downloadName] = mockDownloadFile.mock.calls[0]
|
||||
expect(downloadUrl).toContain('filename=context-name.png')
|
||||
expect(downloadUrl).toContain('type=input')
|
||||
expect(downloadUrl).not.toBe('https://example.com/context-preview.png')
|
||||
expect(downloadName).toBe('Context image.png')
|
||||
expect(mockCreateAssetExport).not.toHaveBeenCalled()
|
||||
expect(mockTrackExport).not.toHaveBeenCalled()
|
||||
|
||||
@@ -520,8 +522,31 @@ describe('useMediaAssetActions', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('downloads the original EXR, not the AVIF preview, when both are present', () => {
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
const asset = createMockAsset({
|
||||
id: 'exr-asset',
|
||||
name: 'render.exr',
|
||||
mime_type: 'image/aces',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/render.avif',
|
||||
thumbnail_url: 'https://example.com/render.avif'
|
||||
})
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([asset])
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledOnce()
|
||||
const [downloadUrl, downloadName] = mockDownloadFile.mock.calls[0]
|
||||
expect(downloadUrl).toContain('filename=render.exr')
|
||||
expect(downloadUrl).toContain('type=output')
|
||||
expect(downloadUrl).not.toContain('avif')
|
||||
expect(downloadName).toBe('render.exr')
|
||||
})
|
||||
|
||||
it('keeps single explicit assets on the direct download path in cloud', () => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockReturnValue({
|
||||
jobId: 'job1',
|
||||
outputCount: 1
|
||||
@@ -539,10 +564,10 @@ describe('useMediaAssetActions', () => {
|
||||
actions.downloadAssets([asset])
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledOnce()
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/single-output.png',
|
||||
'single-output.png'
|
||||
)
|
||||
const [downloadUrl, downloadName] = mockDownloadFile.mock.calls[0]
|
||||
expect(downloadUrl).toContain('filename=single-output.png')
|
||||
expect(downloadUrl).toContain('type=output')
|
||||
expect(downloadName).toBe('single-output.png')
|
||||
expect(mockCreateAssetExport).not.toHaveBeenCalled()
|
||||
expect(mockTrackExport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import { getAssetType } from '../utils/assetTypeUtil'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { renditionFor } from '../utils/assetRenditions'
|
||||
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
|
||||
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
|
||||
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
|
||||
@@ -125,7 +126,9 @@ export function useMediaAssetActions() {
|
||||
try {
|
||||
targetAssets.forEach((asset) => {
|
||||
const filename = getAssetDisplayName(asset)
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
// Download the canonical asset, never a transcoded preview rendition.
|
||||
const downloadUrl =
|
||||
renditionFor(asset, 'download') ?? getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
|
||||
|
||||
243
src/platform/assets/utils/assetRenditions.test.ts
Normal file
243
src/platform/assets/utils/assetRenditions.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
canRenderNatively,
|
||||
renditionFor
|
||||
} from '@/platform/assets/utils/assetRenditions'
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetUrlUtil', () => ({
|
||||
getAssetUrl: vi.fn(
|
||||
(asset: { name: string }) => `/api/view?filename=${asset.name}&type=output`
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((route: string) =>
|
||||
route.startsWith('/api')
|
||||
? `http://host${route}`
|
||||
: `http://host/api${route}`
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
function makeAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: 'image.png',
|
||||
tags: ['output'],
|
||||
...overrides
|
||||
} satisfies AssetItem
|
||||
}
|
||||
|
||||
describe('canRenderNatively', () => {
|
||||
it('accepts common browser-native image types', () => {
|
||||
expect(canRenderNatively('image/png')).toBe(true)
|
||||
expect(canRenderNatively('image/jpeg')).toBe(true)
|
||||
expect(canRenderNatively('image/webp')).toBe(true)
|
||||
expect(canRenderNatively('image/avif')).toBe(true)
|
||||
expect(canRenderNatively('image/gif')).toBe(true)
|
||||
expect(canRenderNatively('image/svg+xml')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts browser-native video and audio types', () => {
|
||||
expect(canRenderNatively('video/mp4')).toBe(true)
|
||||
expect(canRenderNatively('video/webm')).toBe(true)
|
||||
expect(canRenderNatively('audio/mpeg')).toBe(true)
|
||||
expect(canRenderNatively('audio/wav')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-browser-renderable image families (EXR, HDR, RAW, TIFF)', () => {
|
||||
expect(canRenderNatively('image/aces')).toBe(false)
|
||||
expect(canRenderNatively('image/x-exr')).toBe(false)
|
||||
expect(canRenderNatively('image/x-hdr')).toBe(false)
|
||||
expect(canRenderNatively('image/x-adobe-dng')).toBe(false)
|
||||
expect(canRenderNatively('image/tiff')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects opaque file types (latents, models, text)', () => {
|
||||
expect(canRenderNatively('application/octet-stream')).toBe(false)
|
||||
expect(canRenderNatively('model/gltf-binary')).toBe(false)
|
||||
expect(canRenderNatively('text/plain')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles charset / parameter suffixes', () => {
|
||||
expect(canRenderNatively('image/png; charset=utf-8')).toBe(true)
|
||||
expect(canRenderNatively('IMAGE/PNG')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for null, undefined, and empty string', () => {
|
||||
expect(canRenderNatively(null)).toBe(false)
|
||||
expect(canRenderNatively(undefined)).toBe(false)
|
||||
expect(canRenderNatively('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renditionFor', () => {
|
||||
describe('grid surface', () => {
|
||||
it('prefers thumbnail_url when present', () => {
|
||||
const asset = makeAsset({
|
||||
mime_type: 'image/png',
|
||||
thumbnail_url: '/thumb.png',
|
||||
preview_url: '/preview.png'
|
||||
})
|
||||
expect(renditionFor(asset, 'grid')).toBe('http://host/api/thumb.png')
|
||||
})
|
||||
|
||||
it('falls back to preview_url when no thumbnail_url', () => {
|
||||
const asset = makeAsset({
|
||||
mime_type: 'image/png',
|
||||
preview_url: '/preview.png'
|
||||
})
|
||||
expect(renditionFor(asset, 'grid')).toBe('http://host/api/preview.png')
|
||||
})
|
||||
|
||||
it('falls back to canonical URL when renderable and no rendition exists', () => {
|
||||
const asset = makeAsset({ mime_type: 'image/png' })
|
||||
expect(renditionFor(asset, 'grid')).toBe(
|
||||
'/api/view?filename=image.png&type=output'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when not renderable and no rendition exists (EXR case)', () => {
|
||||
const asset = makeAsset({ name: 'render.exr', mime_type: 'image/aces' })
|
||||
expect(renditionFor(asset, 'grid')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses rendition for EXR when backend provides a transcoded preview', () => {
|
||||
const asset = makeAsset({
|
||||
name: 'render.exr',
|
||||
mime_type: 'image/aces',
|
||||
thumbnail_url: '/render.avif',
|
||||
preview_url: '/render.avif'
|
||||
})
|
||||
expect(renditionFor(asset, 'grid')).toBe('http://host/api/render.avif')
|
||||
})
|
||||
|
||||
it('returns null for opaque types with no preview (latent, safetensors)', () => {
|
||||
const asset = makeAsset({
|
||||
name: 'weights.safetensors',
|
||||
mime_type: 'application/octet-stream'
|
||||
})
|
||||
expect(renditionFor(asset, 'grid')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lightbox surface', () => {
|
||||
it('skips thumbnail_url and prefers preview_url', () => {
|
||||
const asset = makeAsset({
|
||||
mime_type: 'image/png',
|
||||
thumbnail_url: '/thumb.png',
|
||||
preview_url: '/full.png'
|
||||
})
|
||||
expect(renditionFor(asset, 'lightbox')).toBe('http://host/api/full.png')
|
||||
})
|
||||
|
||||
it('falls back to canonical URL when renderable and no preview', () => {
|
||||
const asset = makeAsset({ mime_type: 'image/png' })
|
||||
expect(renditionFor(asset, 'lightbox')).toBe(
|
||||
'/api/view?filename=image.png&type=output'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the AVIF rendition for an EXR', () => {
|
||||
const asset = makeAsset({
|
||||
name: 'render.exr',
|
||||
mime_type: 'image/aces',
|
||||
preview_url: '/render.avif'
|
||||
})
|
||||
expect(renditionFor(asset, 'lightbox')).toBe(
|
||||
'http://host/api/render.avif'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when not renderable and no preview exists', () => {
|
||||
const asset = makeAsset({ name: 'render.exr', mime_type: 'image/aces' })
|
||||
expect(renditionFor(asset, 'lightbox')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('newTab surface', () => {
|
||||
it('mirrors lightbox behaviour', () => {
|
||||
const asset = makeAsset({
|
||||
name: 'render.exr',
|
||||
mime_type: 'image/aces',
|
||||
preview_url: '/render.avif'
|
||||
})
|
||||
expect(renditionFor(asset, 'newTab')).toBe('http://host/api/render.avif')
|
||||
})
|
||||
})
|
||||
|
||||
describe('download surface', () => {
|
||||
it('always returns the canonical URL, even when a preview exists', () => {
|
||||
const asset = makeAsset({
|
||||
name: 'render.exr',
|
||||
mime_type: 'image/aces',
|
||||
thumbnail_url: '/render.avif',
|
||||
preview_url: '/render.avif'
|
||||
})
|
||||
expect(renditionFor(asset, 'download')).toBe(
|
||||
'/api/view?filename=render.exr&type=output'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the canonical URL for browser-native assets too', () => {
|
||||
const asset = makeAsset({
|
||||
mime_type: 'image/png',
|
||||
thumbnail_url: '/thumb.png'
|
||||
})
|
||||
expect(renditionFor(asset, 'download')).toBe(
|
||||
'/api/view?filename=image.png&type=output'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL normalization', () => {
|
||||
it('passes server-relative rendition URLs through api.apiURL', () => {
|
||||
const asset = makeAsset({
|
||||
mime_type: 'image/aces',
|
||||
thumbnail_url: '/assets/abc-123/content'
|
||||
})
|
||||
expect(renditionFor(asset, 'grid')).toBe(
|
||||
'http://host/api/assets/abc-123/content'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not double-prefix URLs that already start with /api', () => {
|
||||
const asset = makeAsset({
|
||||
mime_type: 'image/png',
|
||||
thumbnail_url: '/api/view?filename=already.png'
|
||||
})
|
||||
expect(renditionFor(asset, 'grid')).toBe(
|
||||
'http://host/api/view?filename=already.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes absolute http(s) URLs through untouched', () => {
|
||||
const asset = makeAsset({
|
||||
mime_type: 'image/png',
|
||||
thumbnail_url: 'https://cdn.example.com/thumb.png'
|
||||
})
|
||||
expect(renditionFor(asset, 'grid')).toBe(
|
||||
'https://cdn.example.com/thumb.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes blob and data URLs through untouched', () => {
|
||||
const blobAsset = makeAsset({
|
||||
mime_type: 'image/png',
|
||||
thumbnail_url: 'blob:http://host/abc-123'
|
||||
})
|
||||
expect(renditionFor(blobAsset, 'grid')).toBe('blob:http://host/abc-123')
|
||||
|
||||
const dataAsset = makeAsset({
|
||||
mime_type: 'image/png',
|
||||
thumbnail_url: 'data:image/png;base64,iVBORw0KG'
|
||||
})
|
||||
expect(renditionFor(dataAsset, 'grid')).toBe(
|
||||
'data:image/png;base64,iVBORw0KG'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
107
src/platform/assets/utils/assetRenditions.ts
Normal file
107
src/platform/assets/utils/assetRenditions.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { getAssetUrl } from './assetUrlUtil'
|
||||
|
||||
type RenditionSurface = 'grid' | 'lightbox' | 'newTab' | 'download'
|
||||
|
||||
// Rendition URLs from the assets API are typically root-relative (e.g.
|
||||
// `/assets/{id}/content`). Browser-absolute (http/https), blob, and data
|
||||
// URLs are returned by some adapters (LoadImage widget, cloud previews)
|
||||
// and must pass through untouched. Anything else goes via `api.apiURL()`
|
||||
// so it gets the base prefix and `/api` route the server expects.
|
||||
function normalizeRenditionUrl(url: string | null | undefined): string | null {
|
||||
if (!url) return null
|
||||
if (/^(https?:|blob:|data:)/i.test(url)) return url
|
||||
if (url.startsWith('/')) return api.apiURL(url)
|
||||
return url
|
||||
}
|
||||
|
||||
// Image MIME types that every supported browser can render via `<img>`.
|
||||
// Format policy lives here, not in callers. Keep this list narrow and
|
||||
// authoritative — if a MIME isn't on it, the asset gets icon-fallback
|
||||
// treatment regardless of file extension.
|
||||
const RENDERABLE_IMAGE_MIME_TYPES = new Set<string>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
'image/svg+xml',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon'
|
||||
])
|
||||
|
||||
const RENDERABLE_VIDEO_MIME_TYPES = new Set<string>([
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/ogg'
|
||||
])
|
||||
|
||||
const RENDERABLE_AUDIO_MIME_TYPES = new Set<string>([
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/wave',
|
||||
'audio/x-wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/flac',
|
||||
'audio/x-flac'
|
||||
])
|
||||
|
||||
export function canRenderNatively(
|
||||
mimeType: string | null | undefined
|
||||
): boolean {
|
||||
if (!mimeType) return false
|
||||
const normalized = mimeType.toLowerCase().split(';')[0].trim()
|
||||
return (
|
||||
RENDERABLE_IMAGE_MIME_TYPES.has(normalized) ||
|
||||
RENDERABLE_VIDEO_MIME_TYPES.has(normalized) ||
|
||||
RENDERABLE_AUDIO_MIME_TYPES.has(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
interface AssetRenditionFields {
|
||||
mime_type?: string | null
|
||||
preview_url?: string
|
||||
thumbnail_url?: string
|
||||
}
|
||||
|
||||
// Resolves which URL the UI should use for a given display surface, applying
|
||||
// these rules (which align with the planned /assets API contract):
|
||||
//
|
||||
// grid / hover / sidebar: thumbnail_url ?? preview_url ?? (renderable ? canonical : null)
|
||||
// lightbox / new tab: preview_url ?? (renderable ? canonical : null)
|
||||
// download / open / copy: canonical (the original asset, never a transcoded substitute)
|
||||
//
|
||||
// Returning null means "no usable URL for this surface" — callers render an
|
||||
// icon placeholder. The asset itself is never special-cased by extension;
|
||||
// renderability is decided purely from `mime_type` via canRenderNatively().
|
||||
export function renditionFor(
|
||||
asset: AssetItem,
|
||||
surface: RenditionSurface
|
||||
): string | null {
|
||||
const fields: AssetRenditionFields = asset
|
||||
const canonical = getAssetUrl(asset)
|
||||
const renderable = canRenderNatively(fields.mime_type)
|
||||
|
||||
switch (surface) {
|
||||
case 'grid':
|
||||
return (
|
||||
normalizeRenditionUrl(fields.thumbnail_url) ||
|
||||
normalizeRenditionUrl(fields.preview_url) ||
|
||||
(renderable ? canonical : null)
|
||||
)
|
||||
case 'lightbox':
|
||||
case 'newTab':
|
||||
return (
|
||||
normalizeRenditionUrl(fields.preview_url) ||
|
||||
(renderable ? canonical : null)
|
||||
)
|
||||
case 'download':
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { iconForMediaType } from './mediaIconUtil'
|
||||
import { iconForMediaType, iconForMimeType } from './mediaIconUtil'
|
||||
|
||||
describe('iconForMediaType', () => {
|
||||
it('maps text and misc fallbacks correctly', () => {
|
||||
@@ -15,3 +15,52 @@ describe('iconForMediaType', () => {
|
||||
expect(iconForMediaType('3D')).toBe('icon-[lucide--box]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('iconForMimeType', () => {
|
||||
it('maps image MIME types (including non-renderable variants) to file-image', () => {
|
||||
expect(iconForMimeType('image/png')).toBe('icon-[lucide--file-image]')
|
||||
expect(iconForMimeType('image/aces')).toBe('icon-[lucide--file-image]')
|
||||
expect(iconForMimeType('image/x-exr')).toBe('icon-[lucide--file-image]')
|
||||
expect(iconForMimeType('image/tiff')).toBe('icon-[lucide--file-image]')
|
||||
})
|
||||
|
||||
it('maps video MIME types to file-video', () => {
|
||||
expect(iconForMimeType('video/mp4')).toBe('icon-[lucide--file-video]')
|
||||
expect(iconForMimeType('video/webm')).toBe('icon-[lucide--file-video]')
|
||||
})
|
||||
|
||||
it('maps audio MIME types to file-audio', () => {
|
||||
expect(iconForMimeType('audio/mpeg')).toBe('icon-[lucide--file-audio]')
|
||||
expect(iconForMimeType('audio/wav')).toBe('icon-[lucide--file-audio]')
|
||||
})
|
||||
|
||||
it('maps text MIME types to file-text', () => {
|
||||
expect(iconForMimeType('text/plain')).toBe('icon-[lucide--file-text]')
|
||||
expect(iconForMimeType('text/markdown')).toBe('icon-[lucide--file-text]')
|
||||
})
|
||||
|
||||
it('maps model MIME types to the 3D box icon', () => {
|
||||
expect(iconForMimeType('model/gltf-binary')).toBe('icon-[lucide--box]')
|
||||
expect(iconForMimeType('model/obj')).toBe('icon-[lucide--box]')
|
||||
expect(iconForMimeType('model/vnd.usdz+zip')).toBe('icon-[lucide--box]')
|
||||
})
|
||||
|
||||
it('returns the generic file icon for unknown or missing MIME types', () => {
|
||||
expect(iconForMimeType('application/x-safetensors')).toBe(
|
||||
'icon-[lucide--file]'
|
||||
)
|
||||
expect(iconForMimeType('application/json')).toBe('icon-[lucide--file]')
|
||||
expect(iconForMimeType('application/octet-stream')).toBe(
|
||||
'icon-[lucide--file]'
|
||||
)
|
||||
expect(iconForMimeType('')).toBe('icon-[lucide--file]')
|
||||
expect(iconForMimeType(null)).toBe('icon-[lucide--file]')
|
||||
expect(iconForMimeType(undefined)).toBe('icon-[lucide--file]')
|
||||
})
|
||||
|
||||
it('handles parameter suffixes and case', () => {
|
||||
expect(iconForMimeType('image/PNG; charset=utf-8')).toBe(
|
||||
'icon-[lucide--file-image]'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,3 +16,43 @@ export function iconForMediaType(mediaType: MediaKind): string {
|
||||
return 'icon-[lucide--image]'
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_IMAGE_ICON = 'icon-[lucide--file-image]'
|
||||
const FILE_VIDEO_ICON = 'icon-[lucide--file-video]'
|
||||
const FILE_AUDIO_ICON = 'icon-[lucide--file-audio]'
|
||||
const FILE_TEXT_ICON = 'icon-[lucide--file-text]'
|
||||
const FILE_GENERIC_ICON = 'icon-[lucide--file]'
|
||||
const BOX_ICON = 'icon-[lucide--box]'
|
||||
|
||||
const THREE_D_MIME_TYPES = new Set<string>([
|
||||
'model/gltf-binary',
|
||||
'model/gltf+json',
|
||||
'model/obj',
|
||||
'model/vnd.usdz+zip'
|
||||
])
|
||||
|
||||
// MIME-type → icon resolver. Generalises `iconForMediaType` for callers that
|
||||
// have an authoritative MIME type instead of a media-family enum. Used by
|
||||
// asset cards and previews to render a deliberate file-type icon when the
|
||||
// browser cannot decode the asset for in-place display (EXR, RAW, latents,
|
||||
// .safetensors, etc.) rather than the misleading broken-image state.
|
||||
export function iconForMimeType(mimeType: string | null | undefined): string {
|
||||
if (!mimeType) return FILE_GENERIC_ICON
|
||||
const normalized = mimeType.toLowerCase().split(';')[0].trim()
|
||||
const family = normalized.split('/')[0]
|
||||
|
||||
if (THREE_D_MIME_TYPES.has(normalized) || family === 'model') return BOX_ICON
|
||||
|
||||
switch (family) {
|
||||
case 'image':
|
||||
return FILE_IMAGE_ICON
|
||||
case 'video':
|
||||
return FILE_VIDEO_ICON
|
||||
case 'audio':
|
||||
return FILE_AUDIO_ICON
|
||||
case 'text':
|
||||
return FILE_TEXT_ICON
|
||||
default:
|
||||
return FILE_GENERIC_ICON
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1632,6 +1632,7 @@ export class ComfyApp {
|
||||
executionStore.storeJob({
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output),
|
||||
promptOutput: p.output,
|
||||
workflow: queuedWorkflow
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
|
||||
// Create mock functions that will be shared
|
||||
const {
|
||||
@@ -18,10 +22,6 @@ const {
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockShowTextPreview: vi.fn()
|
||||
}))
|
||||
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
@@ -70,7 +70,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/imagePreviewStore', () => ({
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokePreviewsByExecutionId: vi.fn()
|
||||
})
|
||||
@@ -94,6 +94,26 @@ vi.mock('@/scripts/app', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
function createQueuedWorkflow(path: string = 'workflows/test.json') {
|
||||
return {
|
||||
activeState: { id: 'workflow-id' },
|
||||
initialState: { id: 'workflow-id' },
|
||||
path
|
||||
} as Parameters<
|
||||
ReturnType<typeof useExecutionStore>['storeJob']
|
||||
>[0]['workflow']
|
||||
}
|
||||
|
||||
function createPromptNode(title: string, classType: string) {
|
||||
return {
|
||||
inputs: {},
|
||||
class_type: classType,
|
||||
_meta: {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
@@ -709,6 +729,103 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - executingNode with subgraphs', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('should find executing node info in root graph from queued prompt data', () => {
|
||||
store.storeJob({
|
||||
id: 'test-prompt',
|
||||
nodes: ['123'],
|
||||
promptOutput: {
|
||||
'123': createPromptNode('Test Node', 'TestNode')
|
||||
},
|
||||
workflow: createQueuedWorkflow()
|
||||
})
|
||||
store.activeJobId = 'test-prompt'
|
||||
|
||||
store.nodeProgressStates = {
|
||||
'123': {
|
||||
state: 'running',
|
||||
value: 0,
|
||||
max: 100,
|
||||
display_node_id: '123',
|
||||
prompt_id: 'test-prompt',
|
||||
node_id: '123'
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.executingNode).toEqual({
|
||||
title: 'Test Node',
|
||||
type: 'TestNode'
|
||||
})
|
||||
})
|
||||
|
||||
it('should find executing node info in subgraph using execution ID', () => {
|
||||
store.storeJob({
|
||||
id: 'test-prompt',
|
||||
nodes: ['456:789'],
|
||||
promptOutput: {
|
||||
'456:789': createPromptNode('Nested Node', 'NestedNode')
|
||||
},
|
||||
workflow: createQueuedWorkflow()
|
||||
})
|
||||
store.activeJobId = 'test-prompt'
|
||||
|
||||
store.nodeProgressStates = {
|
||||
'456:789': {
|
||||
state: 'running',
|
||||
value: 0,
|
||||
max: 100,
|
||||
display_node_id: '456:789',
|
||||
prompt_id: 'test-prompt',
|
||||
node_id: '456:789'
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.executingNode).toEqual({
|
||||
title: 'Nested Node',
|
||||
type: 'NestedNode'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when no node is executing', () => {
|
||||
store.nodeProgressStates = {}
|
||||
|
||||
expect(store.executingNode).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when executing node metadata cannot be found', () => {
|
||||
store.storeJob({
|
||||
id: 'test-prompt',
|
||||
nodes: ['123'],
|
||||
promptOutput: {
|
||||
'123': createPromptNode('Test Node', 'TestNode')
|
||||
},
|
||||
workflow: createQueuedWorkflow()
|
||||
})
|
||||
store.activeJobId = 'test-prompt'
|
||||
|
||||
store.nodeProgressStates = {
|
||||
'999': {
|
||||
state: 'running',
|
||||
value: 0,
|
||||
max: 100,
|
||||
display_node_id: '999',
|
||||
prompt_id: 'test-prompt',
|
||||
node_id: '999'
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.executingNode).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
|
||||
let store: ReturnType<typeof useMissingNodesErrorStore>
|
||||
|
||||
@@ -1076,9 +1193,21 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
|
||||
path: '/workflows/foo.json'
|
||||
} as unknown as Parameters<typeof store.storeJob>[0]['workflow']
|
||||
|
||||
store.storeJob({ nodes: ['a', 'b'], id: 'job-1', workflow })
|
||||
store.storeJob({
|
||||
nodes: ['a', 'b'],
|
||||
id: 'job-1',
|
||||
promptOutput: {
|
||||
a: createPromptNode('Node A', 'NodeA'),
|
||||
b: createPromptNode('Node B', 'NodeB')
|
||||
},
|
||||
workflow
|
||||
})
|
||||
|
||||
expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false, b: false })
|
||||
expect(store.queuedJobs['job-1']?.nodeLookup).toEqual({
|
||||
a: { title: 'Node A', type: 'NodeA' },
|
||||
b: { title: 'Node B', type: 'NodeB' }
|
||||
})
|
||||
expect(store.queuedJobs['job-1']?.workflow).toStrictEqual(workflow)
|
||||
expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
|
||||
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
|
||||
|
||||
@@ -7,8 +7,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type {
|
||||
ComfyNode,
|
||||
ComfyWorkflowJSON,
|
||||
ComfyApiWorkflow,
|
||||
NodeId,
|
||||
WorkflowId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -36,6 +35,11 @@ import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface ExecutionNodeInfo {
|
||||
title?: string | null
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
interface QueuedJob {
|
||||
/**
|
||||
* The nodes that are queued to be executed. The key is the node id and the
|
||||
@@ -46,6 +50,25 @@ interface QueuedJob {
|
||||
* The workflow that is queued to be executed
|
||||
*/
|
||||
workflow?: ComfyWorkflow
|
||||
/**
|
||||
* Queue-time node metadata keyed by execution ID.
|
||||
* This stays stable even if the user switches workflows or edits the canvas.
|
||||
*/
|
||||
nodeLookup?: Record<string, ExecutionNodeInfo>
|
||||
}
|
||||
|
||||
function buildExecutionNodeLookup(
|
||||
promptOutput: ComfyApiWorkflow
|
||||
): Record<string, ExecutionNodeInfo> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(promptOutput).map(([executionId, node]) => [
|
||||
executionId,
|
||||
{
|
||||
title: node._meta.title,
|
||||
type: node.class_type
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,21 +191,11 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
() => new Set(executingNodeIds.value.map(String))
|
||||
)
|
||||
|
||||
// For backward compatibility - returns the primary executing node
|
||||
const executingNode = computed<ComfyNode | null>(() => {
|
||||
// For backward compatibility - returns the primary executing node info
|
||||
const executingNode = computed<ExecutionNodeInfo | null>(() => {
|
||||
if (!executingNodeId.value) return null
|
||||
|
||||
const workflow: ComfyWorkflow | undefined = activeJob.value?.workflow
|
||||
if (!workflow) return null
|
||||
|
||||
const canvasState: ComfyWorkflowJSON | null =
|
||||
workflow.changeTracker?.activeState ?? null
|
||||
if (!canvasState) return null
|
||||
|
||||
return (
|
||||
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
|
||||
null
|
||||
)
|
||||
return activeJob.value?.nodeLookup?.[String(executingNodeId.value)] ?? null
|
||||
})
|
||||
|
||||
// This is the progress of the currently executing node (for backward compatibility)
|
||||
@@ -548,10 +561,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
function storeJob({
|
||||
nodes,
|
||||
id,
|
||||
promptOutput,
|
||||
workflow
|
||||
}: {
|
||||
nodes: string[]
|
||||
id: JobId
|
||||
promptOutput: ComfyApiWorkflow
|
||||
workflow: ComfyWorkflow
|
||||
}) {
|
||||
queuedJobs.value[id] ??= { nodes: {} }
|
||||
@@ -563,6 +578,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}, {}),
|
||||
...queuedJob.nodes
|
||||
}
|
||||
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
|
||||
queuedJob.workflow = workflow
|
||||
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
|
||||
if (wid) {
|
||||
|
||||
@@ -148,6 +148,69 @@ describe('useModelStore', () => {
|
||||
expect(api.getModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('refreshModelFolder', () => {
|
||||
it('re-fetches the contents of a previously loaded folder', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
expect(api.getModels).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.mocked(api.getModels).mockResolvedValueOnce([
|
||||
{ name: 'sdxl.safetensors', pathIndex: 0 },
|
||||
{ name: 'sdv15.safetensors', pathIndex: 0 },
|
||||
{ name: 'noinfo.safetensors', pathIndex: 0 },
|
||||
{ name: 'new-upload.safetensors', pathIndex: 0 }
|
||||
])
|
||||
|
||||
await store.refreshModelFolder('checkpoints')
|
||||
|
||||
expect(api.getModels).toHaveBeenCalledTimes(2)
|
||||
const folder = await store.getLoadedModelFolder('checkpoints')
|
||||
expect(Object.keys(folder!.models)).toHaveLength(4)
|
||||
expect(folder!.models['0/new-upload.safetensors']).toBeDefined()
|
||||
})
|
||||
|
||||
it('falls back to refreshing folder structure when folder is unknown', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
|
||||
|
||||
await store.refreshModelFolder('loras')
|
||||
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(api.getModels).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh', () => {
|
||||
it('re-loads only folders that were previously loaded', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
expect(api.getModels).toHaveBeenCalledTimes(1)
|
||||
|
||||
await store.refresh()
|
||||
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(api.getModels).toHaveBeenCalledTimes(2)
|
||||
expect(api.getModels).toHaveBeenLastCalledWith('checkpoints')
|
||||
})
|
||||
|
||||
it('does not load folders that were never opened', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
|
||||
await store.refresh()
|
||||
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
|
||||
expect(api.getModels).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API switching functionality', () => {
|
||||
it('should use experimental API for complete workflow when UseAssetAPI setting is false', async () => {
|
||||
enableMocks(false) // useAssetAPI = false
|
||||
|
||||
@@ -205,6 +205,13 @@ export const useModelStore = defineStore('models', () => {
|
||||
modelFolders.value.flatMap((folder) => Object.values(folder.models))
|
||||
)
|
||||
|
||||
function createGetModelsFunc(): (folder: string) => Promise<ModelFile[]> {
|
||||
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
return useAssetAPI
|
||||
? (folder) => assetService.getAssetModels(folder)
|
||||
: (folder) => api.getModels(folder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the model folders from the server
|
||||
*/
|
||||
@@ -216,11 +223,8 @@ export const useModelStore = defineStore('models', () => {
|
||||
: await api.getModelFolders()
|
||||
modelFolderNames.value = resData.map((folder) => folder.name)
|
||||
modelFolderByName.value = {}
|
||||
const getModelsFunc = createGetModelsFunc()
|
||||
for (const folderName of modelFolderNames.value) {
|
||||
const getModelsFunc = useAssetAPI
|
||||
? (folder: string) => assetService.getAssetModels(folder)
|
||||
: (folder: string) => api.getModels(folder)
|
||||
|
||||
modelFolderByName.value[folderName] = new ModelFolder(
|
||||
folderName,
|
||||
getModelsFunc
|
||||
@@ -242,11 +246,48 @@ export const useModelStore = defineStore('models', () => {
|
||||
return Promise.all(modelFolders.value.map((folder) => folder.load()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards the cache for a single folder and re-loads its contents.
|
||||
* Use when on-disk contents of that folder have changed (e.g. after upload).
|
||||
* Falls back to refreshing the whole library when the folder is unknown so
|
||||
* a newly-introduced folder type is picked up without dropping other
|
||||
* folders' loaded contents.
|
||||
*/
|
||||
async function refreshModelFolder(folderName: string) {
|
||||
if (!(folderName in modelFolderByName.value)) {
|
||||
await refresh()
|
||||
return
|
||||
}
|
||||
const folder = new ModelFolder(folderName, createGetModelsFunc())
|
||||
await folder.load()
|
||||
modelFolderByName.value[folderName] = folder
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the folder structure and re-loads any folder whose contents
|
||||
* had previously been loaded. Used by manual refresh actions ("r" key,
|
||||
* sidebar refresh button) to pick up on-disk changes without losing the
|
||||
* currently-visible contents.
|
||||
*/
|
||||
async function refresh() {
|
||||
const previouslyLoaded = modelFolders.value
|
||||
.filter((folder) => folder.state === ResourceState.Loaded)
|
||||
.map((folder) => folder.directory)
|
||||
await loadModelFolders()
|
||||
await Promise.all(
|
||||
previouslyLoaded
|
||||
.filter((name) => name in modelFolderByName.value)
|
||||
.map((name) => modelFolderByName.value[name].load())
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
models,
|
||||
modelFolders,
|
||||
loadModelFolders,
|
||||
loadModels,
|
||||
getLoadedModelFolder
|
||||
getLoadedModelFolder,
|
||||
refreshModelFolder,
|
||||
refresh
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user