Compare commits

...

5 Commits

Author SHA1 Message Date
glary-bot
293fde098d fix(assets): address CodeRabbit review
- MediaImageTop.vue: replace forbidden `:class="[]"` array merge with the
  project's `cn()` utility per AGENTS.md.
- assetRenditions.test.ts: type the makeAsset fixture as
  Partial<AssetItem> + `satisfies AssetItem` instead of `as never`, so
  schema drift surfaces at compile time.
2026-05-20 03:46:33 +00:00
glary-bot
6f3bacf483 feat(assets): generalized rendition resolver + MIME-type icon mapping
Introduces a single helper that decides which URL to use per display
surface (grid / lightbox / new tab / download) using the priority chain
the team agreed on:

  grid:     thumbnail_url ?? preview_url ?? (renderable ? canonical : null)
  lightbox: preview_url ?? (renderable ? canonical : null)
  download: canonical (never a transcoded preview rendition)

Renderability is decided from `asset.mime_type` against a small allowlist
of browser-native MIME types; format policy lives in one place and the
helper is generalised so backends can extend the matrix (HEIC, RAW, TIFF,
PSD, etc.) without UI changes. EXR isn't named anywhere in the code path.

Three concrete fixes for the SaveImageAdvanced / EXR product case:

- MediaAssetCard now routes non-3D assets through the rendition resolver
  instead of an ad-hoc `thumbnail_url || preview_url || ''` chain, so
  non-browser-renderable originals fall through to an intentional
  placeholder instead of a broken-image state.

- useMediaAssetActions.downloadAssets no longer falls back to the
  preview rendition for downloads. Previously `preview_url ||
  getAssetUrl(asset)` would silently deliver an AVIF when the user
  clicked Download on an EXR; downloads now always hit the canonical
  asset URL. Two existing tests updated to match the corrected
  behaviour, plus a new test that codifies the EXR-with-AVIF case.

- MediaImageTop's load-error fallback shows a deliberate placeholder
  (MIME-aware lucide icon, filename, 'Preview not available') in place
  of the previous primeicons broken-image state.

iconForMimeType generalises iconForMediaType so any caller with an
authoritative MIME type gets a sensible file-family icon (image, video,
audio, text, model) without having to translate through MediaKind first.

Aligns with the planned /assets API contract: renditionFor() is the
single integration point that needs updating when the API gains
structured rendition fields, instead of touching 47 ad-hoc call sites.
2026-05-20 03:38:56 +00:00
Dante
d955625c20 fix(model-library): auto-refresh after upload, plus 'r' key fallback (FE-695) (#12257)
## Summary

Model Library sidebar now refreshes automatically when an upload
completes, and the `r` keybinding refreshes the library in addition to
refreshing combo widgets inside graph nodes.

## Changes

- **What**:
- `modelStore`: new `refreshModelFolder(name)` for surgical reset+reload
of one folder, and `refresh()` that re-loads any folders that had been
loaded
- `ModelLibrarySidebarTab.vue`: watches
`assetDownloadStore.lastCompletedDownload` and refreshes the affected
folder; the in-panel refresh button now routes through `refresh()`
- `Comfy.RefreshNodeDefinitions` (`r` key): also calls
`modelStore.refresh()` so the keyboard fallback actually refreshes the
Model Library list

## Review Focus

- Both `modelStore` and `assetsStore` exist; the upload wizard was only
refreshing the latter, which is what caused the bug. Confirm the new
watcher path is the right hook (rather than wiring it inside the wizard)
— chose this so it also covers completions that happen after the wizard
has been closed.
- `refreshModelFolder` falls back to `refresh()` (not raw
`loadModelFolders()`) for unknown folder types, to avoid dropping other
folders' loaded contents.
- Generated tab half of the ticket is intentionally **deferred** until
BE-885 (cursor pagination on `GET /api/jobs`) lands — AC items around
"no duplicates" and "cursor state maintained" depend on it.

Fixes FE-695 (Model Library half).

## Screenshots (if applicable)

N/A — behavior change verified by unit tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12257-fix-model-library-auto-refresh-after-upload-plus-r-key-fallback-3606d73d3650811a8895ef6e3ef2b4b8)
by [Unito](https://www.unito.io)
2026-05-16 13:41:25 +00:00
Terry Jia
861d737041 FE-702: rehydrate 3D viewer on subgraph re-entry via persistent ready hook (#12294)
## Summary
When a Preview3D / Load3D / SaveGLB node lives inside a subgraph, the 3D
viewer correctly displays the model the first time you enter the
subgraph but is blank after exiting and re-entering — even though
`node.properties['Last Time Model File']` is still populated and the
underlying file is on disk.

Fix: introduce a persistent companion to `waitForLoad3d` in
`useLoad3d.ts`:

- `onLoad3dReady(callback)` — registers a callback that fires on *every*
(re-)initialization of the `Load3d` instance for a given node, not just
the first one. Cleared automatically when the node is removed from the
graph (chained into `node.onRemoved` alongside the existing
`pendingCallbacks` cleanup).
- `waitForLoad3d` keeps its original one-shot semantics so callbacks
that install per-node side effects (e.g. wrapping `node.onExecuted`,
setting `sceneWidget.serializeValue`) do not chain on remount.
- When `onLoad3dReady` is registered after a `Load3d` instance already
exists, the callback fires synchronously as well, so the same code path
covers both initial setup and subsequent rehydrations.

Preview3D / Load3D / SaveGLB move the "reapply state from
`node.properties` / `model_file` widget to the Load3d viewer" block from
`waitForLoad3d` to `onLoad3dReady`.
First mount and every subsequent remount now run identical rehydration
code, with `node.properties['Last Time Model File']` (already
workflow-JSON-serialised) as the single source of truth.

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/e4b0fe6f-c898-4210-b545-7ad6883ed722

after

https://github.com/user-attachments/assets/a4a28490-071d-4694-87a8-5eaa501ac168

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12294-FE-702-rehydrate-3D-viewer-on-subgraph-re-entry-via-persistent-ready-hook-3616d73d3650811e93e7dedb32762711)
by [Unito](https://www.unito.io)
2026-05-16 05:42:04 -04:00
Csongor Czezar
7160a9ee3f fix: QPO progress bar now shows node name in subgraphs (#7688)
## Summary

Resolve the queue progress node label from queued prompt metadata so
subgraph execution IDs show the correct node name without depending on
the live canvas.

## Changes

- **What**: Store a prompt-scoped `executionId -> { title, type }`
lookup from `p.output` when queueing a job, and use that lookup for the
active job's executing node label.
- **What**: Reuse the same job-scoped node info for the browser tab
title so it stays aligned with the queue overlay.
- **What**: Add unit coverage for root and subgraph execution IDs, and
merge the branch forward to current `main`.

## Review Focus

This keeps the fix scoped to the existing singular `activeJobId` path.
It fixes subgraph labels and avoids the workflow-switching regression
from resolving against `app.rootGraph`, but it does not redesign
concurrent multi-job selection yet.

Longer term, the cleaner solution is still prompt-scoped execution
metadata from the backend rather than frontend reconstruction.

## Screenshots (if applicable)

N/A

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-15 14:00:33 -07:00
28 changed files with 1203 additions and 144 deletions

View File

@@ -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"

View File

@@ -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 ({

View File

@@ -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()
})
})

View File

@@ -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')) {

View File

@@ -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()

View File

@@ -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(

View File

@@ -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()
})
})

View File

@@ -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()])
}
},
{

View File

@@ -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)
})
})
})

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)

View File

@@ -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({

View File

@@ -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

View File

@@ -3097,6 +3097,7 @@
}
},
"mediaAsset": {
"previewNotAvailable": "Preview not available",
"deleteAssetTitle": "Delete this asset?",
"deleteAssetDescription": "This asset will be permanently removed.",
"deleteSelectedTitle": "Delete selected assets?",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()
})

View File

@@ -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)
})

View 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'
)
})
})
})

View 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
}
}

View File

@@ -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]'
)
})
})

View File

@@ -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
}
}

View File

@@ -1632,6 +1632,7 @@ export class ComfyApp {
executionStore.storeJob({
id: res.prompt_id,
nodes: Object.keys(p.output),
promptOutput: p.output,
workflow: queuedWorkflow
})
}

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
}
})