+
{{ $t('assetBrowser.modelAssociatedWithLink') }}
@@ -39,6 +42,7 @@
"
:options="modelTypes"
:disabled="isLoading"
+ :content-style="selectContentStyle"
data-attr="upload-model-step2-type-selector"
/>
@@ -47,6 +51,7 @@
diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
index d4b1deaff1..b9936caa2f 100644
--- a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
+++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
@@ -77,7 +77,7 @@
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
/>
-
+
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
+import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
@@ -257,9 +258,10 @@ const accordionClass = cn(
'border-t border-border-default bg-modal-panel-background'
)
-const { asset, cacheKey } = defineProps<{
+const { asset, cacheKey, selectContentStyle } = defineProps<{
asset: AssetDisplayItem
cacheKey?: string
+ selectContentStyle?: StyleValue
}>()
const assetsStore = useAssetsStore()
diff --git a/src/platform/assets/composables/media/assetMappers.test.ts b/src/platform/assets/composables/media/assetMappers.test.ts
new file mode 100644
index 0000000000..a4667a234e
--- /dev/null
+++ b/src/platform/assets/composables/media/assetMappers.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { mapInputFileToAssetItem } from './assetMappers'
+
+vi.mock('@/scripts/api', () => ({
+ api: {
+ apiURL: (path: string) => `/api${path}`
+ }
+}))
+
+vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
+ appendCloudResParam: vi.fn()
+}))
+
+describe('mapInputFileToAssetItem', () => {
+ it('preserves a clean filename', () => {
+ const asset = mapInputFileToAssetItem('photo.png', 0, 'input')
+
+ expect(asset.name).toBe('photo.png')
+ expect(asset.id).toBe('input-0-photo.png')
+ expect(asset.preview_url).toBe('/api/view?filename=photo.png&type=input')
+ })
+
+ it.each([
+ ['photo.png [input]', 'photo.png'],
+ ['photo.png [output]', 'photo.png'],
+ ['photo.png [temp]', 'photo.png'],
+ ['clip.mp4[input]', 'clip.mp4'],
+ ['MyFile.WEBP [Input]', 'MyFile.WEBP']
+ ])('strips ComfyUI directory annotation: %s -> %s', (input, expectedName) => {
+ const asset = mapInputFileToAssetItem(input, 1, 'input')
+
+ expect(asset.name).toBe(expectedName)
+ expect(asset.id).toBe(`input-1-${expectedName}`)
+ expect(asset.preview_url).toBe(
+ `/api/view?filename=${encodeURIComponent(expectedName)}&type=input`
+ )
+ })
+
+ it('leaves non-annotation brackets in the filename intact', () => {
+ const asset = mapInputFileToAssetItem('my [draft] image.png', 0, 'input')
+
+ expect(asset.name).toBe('my [draft] image.png')
+ })
+
+ it('uses the directory passed in for the type query param', () => {
+ const asset = mapInputFileToAssetItem('clip.mp4 [output]', 0, 'output')
+
+ expect(asset.preview_url).toBe('/api/view?filename=clip.mp4&type=output')
+ expect(asset.tags).toEqual(['output'])
+ })
+})
diff --git a/src/platform/assets/composables/media/assetMappers.ts b/src/platform/assets/composables/media/assetMappers.ts
index da468ca9b3..67544a0e87 100644
--- a/src/platform/assets/composables/media/assetMappers.ts
+++ b/src/platform/assets/composables/media/assetMappers.ts
@@ -51,6 +51,17 @@ export function mapTaskOutputToAssetItem(
}
}
+/**
+ * Strips ComfyUI's trailing directory-type annotation (e.g. ` [input]`,
+ * ` [output]`, `[temp]`) from a filename returned by the OSS internal
+ * `/internal/files/{type}` endpoint. The annotation is part of the wire
+ * format LoadImage-style widgets expect, but for the assets sidebar we
+ * want the canonical on-disk filename so type detection / titles work.
+ */
+function stripDirectoryAnnotation(filename: string): string {
+ return filename.replace(/\s*\[(?:input|output|temp)\]\s*$/i, '')
+}
+
/**
* Maps input directory file to AssetItem format
* @param filename The filename
@@ -63,13 +74,14 @@ export function mapInputFileToAssetItem(
index: number,
directory: 'input' | 'output' = 'input'
): AssetItem {
- const params = new URLSearchParams({ filename, type: directory })
+ const cleanName = stripDirectoryAnnotation(filename)
+ const params = new URLSearchParams({ filename: cleanName, type: directory })
const preview_url = api.apiURL(`/view?${params}`)
- appendCloudResParam(params, filename)
+ appendCloudResParam(params, cleanName)
return {
- id: `${directory}-${index}-${filename}`,
- name: filename,
+ id: `${directory}-${index}-${cleanName}`,
+ name: cleanName,
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
diff --git a/src/platform/assets/composables/useAssetBrowser.perf.test.ts b/src/platform/assets/composables/useAssetBrowser.perf.test.ts
new file mode 100644
index 0000000000..b15b44a492
--- /dev/null
+++ b/src/platform/assets/composables/useAssetBrowser.perf.test.ts
@@ -0,0 +1,113 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+
+import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import * as assetMetadataUtils from '@/platform/assets/utils/assetMetadataUtils'
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+}))
+
+vi.mock('@/i18n', () => ({
+ t: (key: string) => key,
+ d: (date: Date) => date.toLocaleDateString()
+}))
+
+const ASSET_COUNT = 200
+const CATEGORIES = ['inputs', 'outputs'] as const
+const TAB_SWITCHES = 6
+
+function makeAsset(index: number): AssetItem {
+ const category = CATEGORIES[index % CATEGORIES.length]
+ return {
+ id: `asset-${index}`,
+ name: `asset-${index}.safetensors`,
+ asset_hash: `blake3:${index}`,
+ size: 1024,
+ mime_type: 'application/octet-stream',
+ tags: ['models', category],
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ last_access_time: '2024-01-01T00:00:00Z',
+ is_immutable: false
+ }
+}
+
+describe('useAssetBrowser - filter tab switching perf (FE-229)', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.restoreAllMocks()
+ })
+
+ it('does not re-transform every asset on each filter tab switch', async () => {
+ const assets = Array.from({ length: ASSET_COUNT }, (_, i) => makeAsset(i))
+ const filenameSpy = vi.spyOn(assetMetadataUtils, 'getAssetFilename')
+
+ const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
+
+ // Initial materialization of the 'all' tab.
+ void filteredAssets.value
+ await nextTick()
+ const baselineCalls = filenameSpy.mock.calls.length
+
+ // Simulate the user clicking back and forth between All / Inputs / Outputs.
+ const tabs: ('all' | 'inputs' | 'outputs')[] = [
+ 'inputs',
+ 'outputs',
+ 'all',
+ 'inputs',
+ 'outputs',
+ 'all'
+ ]
+ expect(tabs).toHaveLength(TAB_SWITCHES)
+
+ for (const tab of tabs) {
+ selectedNavItem.value = tab
+ void filteredAssets.value
+ await nextTick()
+ }
+
+ const switchCalls = filenameSpy.mock.calls.length - baselineCalls
+
+ // Naive (no memoization) cost is approximately:
+ // inputs (100) + outputs (100) + all (200) + inputs (100) + outputs (100) + all (200) = 800.
+ // With per-asset memoization the same asset object should never be transformed twice,
+ // so total work across all tab switches must stay within a small multiple of ASSET_COUNT.
+ const budget = ASSET_COUNT * 2
+ expect(switchCalls).toBeLessThanOrEqual(budget)
+ })
+
+ it('returns identical display item references for unchanged assets across tab switches', async () => {
+ const assets = Array.from({ length: ASSET_COUNT }, (_, i) => makeAsset(i))
+
+ const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
+
+ const firstAllSnapshot = new Map(
+ filteredAssets.value.map((item) => [item.id, item])
+ )
+ await nextTick()
+
+ selectedNavItem.value = 'inputs'
+ void filteredAssets.value
+ await nextTick()
+
+ selectedNavItem.value = 'all'
+ const secondAll = filteredAssets.value
+ await nextTick()
+
+ // If transformAssetForDisplay is memoized per asset, the display items for
+ // the unchanged underlying assets should be the very same object identity
+ // when we navigate back to 'all'. Without memoization every re-render
+ // produces brand-new objects, which forces downstream components
+ // (AssetGrid / AssetCard) to re-render every card.
+ const reusedReferences = secondAll.filter(
+ (item) => firstAllSnapshot.get(item.id) === item
+ ).length
+
+ expect(reusedReferences).toBe(ASSET_COUNT)
+ })
+})
diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts
index 6879ae8125..03f042a4de 100644
--- a/src/platform/assets/composables/useAssetBrowser.ts
+++ b/src/platform/assets/composables/useAssetBrowser.ts
@@ -4,7 +4,7 @@ import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import { storeToRefs } from 'pinia'
-import { d, t } from '@/i18n'
+import { t } from '@/i18n'
import type {
AssetFilterState,
OwnershipOption
@@ -38,12 +38,51 @@ export interface AssetDisplayItem extends AssetItem {
secondaryText: string
badges: AssetBadge[]
stats: {
- formattedDate?: string
downloadCount?: string
stars?: string
}
}
+const displayItemCache = new WeakMap()
+
+function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
+ const badges: AssetBadge[] = []
+
+ const typeTag = asset.tags.find((tag) => tag !== 'models')
+ if (typeTag) {
+ const badgeLabel = typeTag.includes('/')
+ ? typeTag.substring(typeTag.indexOf('/') + 1)
+ : typeTag
+
+ badges.push({ label: badgeLabel, type: 'type' })
+ }
+
+ for (const model of getAssetBaseModels(asset)) {
+ badges.push({ label: model, type: 'base' })
+ }
+
+ // Intentionally no formatted date here — the WeakMap caches by AssetItem
+ // reference, so a pre-formatted string would pin the locale active at first
+ // transform. AssetCard formats `created_at` at render via `d()` instead.
+ return {
+ ...asset,
+ secondaryText: getAssetFilename(asset),
+ badges,
+ stats: {
+ downloadCount: undefined,
+ stars: undefined
+ }
+ }
+}
+
+function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
+ const cached = displayItemCache.get(asset)
+ if (cached) return cached
+ const built = buildDisplayItem(asset)
+ displayItemCache.set(asset, built)
+ return built
+}
+
/**
* Asset Browser composable
* Manages search, filtering, asset transformation and selection logic
@@ -82,46 +121,6 @@ export function useAssetBrowser(
return selectedNavItem.value
})
- // Transform API asset to display asset
- function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
- const secondaryText = getAssetFilename(asset)
-
- const badges: AssetBadge[] = []
-
- const typeTag = asset.tags.find((tag) => tag !== 'models')
- // Type badge from non-root tag
- if (typeTag) {
- // Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
- const badgeLabel = typeTag.includes('/')
- ? typeTag.substring(typeTag.indexOf('/') + 1)
- : typeTag
-
- badges.push({ label: badgeLabel, type: 'type' })
- }
-
- // Base model badges from metadata
- const baseModels = getAssetBaseModels(asset)
- for (const model of baseModels) {
- badges.push({ label: model, type: 'base' })
- }
-
- // Create display stats from API data
- const stats = {
- formattedDate: asset.created_at
- ? d(new Date(asset.created_at), { dateStyle: 'short' })
- : undefined,
- downloadCount: undefined, // Not available in API
- stars: undefined // Not available in API
- }
-
- return {
- ...asset,
- secondaryText,
- badges,
- stats
- }
- }
-
const typeCategories = computed(() => {
const categories = assets.value
.filter((asset) => asset.tags.includes(MODELS_TAG))
diff --git a/src/platform/assets/composables/useMediaAssetActions.test.ts b/src/platform/assets/composables/useMediaAssetActions.test.ts
index 375a90abec..c5f558e0b6 100644
--- a/src/platform/assets/composables/useMediaAssetActions.test.ts
+++ b/src/platform/assets/composables/useMediaAssetActions.test.ts
@@ -167,6 +167,52 @@ vi.mock('@/scripts/api', () => ({
}
}))
+const mockAppGraph = vi.hoisted(() => ({ value: { _nodes: [] as unknown[] } }))
+vi.mock('@/scripts/app', () => ({
+ app: {
+ get graph() {
+ return mockAppGraph.value
+ },
+ get rootGraph() {
+ return mockAppGraph.value
+ }
+ }
+}))
+
+const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
+const mockRemoveNodeOutputsForNode = vi.hoisted(() => vi.fn())
+vi.mock('@/stores/nodeOutputStore', () => ({
+ useNodeOutputStore: () => ({
+ removeNodeOutputs: mockRemoveNodeOutputs,
+ removeNodeOutputsForNode: mockRemoveNodeOutputsForNode
+ })
+}))
+
+const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
+ useWorkflowStore: () => ({
+ activeWorkflow: {
+ changeTracker: { captureCanvasState: mockCaptureCanvasState }
+ }
+ })
+}))
+
+const mockClearNodePreviewCache = vi.hoisted(() => vi.fn())
+vi.mock('../utils/clearNodePreviewCacheForValues', () => ({
+ clearNodePreviewCacheForValues: mockClearNodePreviewCache,
+ findNodesReferencingValues: vi.fn(() => [])
+}))
+
+const mockClearWidgetValues = vi.hoisted(() => vi.fn())
+vi.mock('../utils/clearDeletedAssetWidgetValues', () => ({
+ clearDeletedAssetWidgetValues: mockClearWidgetValues
+}))
+
+const mockMarkMissingMedia = vi.hoisted(() => vi.fn())
+vi.mock('../utils/markDeletedAssetsAsMissingMedia', () => ({
+ markDeletedAssetsAsMissingMedia: mockMarkMissingMedia
+}))
+
function createMockAsset(overrides: Partial = {}): AssetItem {
return {
id: 'test-asset-id',
@@ -793,4 +839,120 @@ describe('useMediaAssetActions', () => {
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
})
})
+
+ describe('deleteAssets — FE-230 preview cache clearing', () => {
+ beforeEach(() => {
+ mockIsCloud.value = true
+ mockGetAssetType.mockReturnValue('input')
+ mockDeleteAsset.mockReset()
+ mockShowDialog.mockImplementation(
+ (opts: {
+ props: {
+ onConfirm: () => Promise | void
+ }
+ }) => {
+ void opts.props.onConfirm()
+ }
+ )
+ mockAppGraph.value = { _nodes: [] }
+ })
+
+ it('invokes clearNodePreviewCacheForValues with canonical widget-value variants', async () => {
+ mockDeleteAsset.mockResolvedValue(undefined)
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-match',
+ name: 'foo.png',
+ asset_hash: 'abc123.png',
+ tags: ['input']
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
+ })
+ const [graphArg, valuesArg, removeArg] =
+ mockClearNodePreviewCache.mock.calls[0]
+ expect(graphArg).toBe(mockAppGraph.value)
+ expect(valuesArg).toEqual(
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+ expect(typeof removeArg).toBe('function')
+
+ const sampleNode = { id: 42 }
+ removeArg(sampleNode)
+ expect(mockRemoveNodeOutputsForNode).toHaveBeenCalledWith(sampleNode)
+ // Locator is resolved from the node's own graph, not from the raw id —
+ // covers Load Image / Load Video nodes nested inside subgraphs.
+ expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
+
+ expect(mockClearWidgetValues).toHaveBeenCalledWith(
+ mockAppGraph.value,
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+
+ expect(mockMarkMissingMedia).toHaveBeenCalledWith(
+ mockAppGraph.value,
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+
+ // markMissing + previewCache must run before widget-value clearing,
+ // otherwise findNodesReferencingValues sees blanked widgets and matches
+ // nothing.
+ const markOrder = mockMarkMissingMedia.mock.invocationCallOrder[0]
+ const cacheOrder = mockClearNodePreviewCache.mock.invocationCallOrder[0]
+ const clearOrder = mockClearWidgetValues.mock.invocationCallOrder[0]
+ expect(markOrder).toBeLessThan(clearOrder)
+ expect(cacheOrder).toBeLessThan(clearOrder)
+
+ // Programmatic widget mutation doesn't go through DOM events, so the
+ // workflow won't be flagged as modified unless we capture explicitly.
+ expect(mockCaptureCanvasState).toHaveBeenCalled()
+ })
+
+ it('emits the [output]-annotated variant for output assets, including subfolder', async () => {
+ mockDeleteAsset.mockResolvedValue(undefined)
+ mockGetAssetType.mockReturnValue('output')
+ mockGetOutputAssetMetadata.mockReturnValue({
+ subfolder: 'outputs/2025'
+ })
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-output',
+ name: 'gen.png',
+ tags: ['output']
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
+ })
+ const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
+ expect(valuesArg).toEqual(new Set(['outputs/2025/gen.png [output]']))
+ expect(valuesArg.has('gen.png')).toBe(false)
+ expect(valuesArg.has('gen.png [input]')).toBe(false)
+ })
+
+ it('omits filenames of failed deletions and skips the helper when nothing was deleted', async () => {
+ mockDeleteAsset.mockRejectedValue(new Error('boom'))
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-failed',
+ name: 'failed.png',
+ asset_hash: 'failhash.png'
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockDeleteAsset).toHaveBeenCalled()
+ })
+ expect(mockClearNodePreviewCache).not.toHaveBeenCalled()
+ expect(mockClearWidgetValues).not.toHaveBeenCalled()
+ expect(mockMarkMissingMedia).not.toHaveBeenCalled()
+ expect(mockCaptureCanvasState).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts
index 3f9b8e6ab7..6b0a9a631c 100644
--- a/src/platform/assets/composables/useMediaAssetActions.ts
+++ b/src/platform/assets/composables/useMediaAssetActions.ts
@@ -7,16 +7,22 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
+import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import { api } from '@/scripts/api'
+import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
+import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import { getAssetType } from '../utils/assetTypeUtil'
import { getAssetUrl } from '../utils/assetUrlUtil'
+import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
+import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
+import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
import { getAssetOutputCount } from '../utils/outputAssetUtil'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
@@ -30,6 +36,35 @@ import { assetService } from '../services/assetService'
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
+/**
+ * Canonical widget-value strings that may reference this asset, scoped by the
+ * asset's source type so basenames cannot cross-match across input/output.
+ *
+ * Output assets emit ` [output]` (and the subfolder-prefixed form when
+ * present in metadata). Input/temp assets emit the bare name plus the explicit
+ * annotation. `asset_hash` is included whenever present, since cloud-stored
+ * assets can be referenced by hash.
+ */
+function widgetValueVariantsForAsset(asset: AssetItem): string[] {
+ const variants: string[] = []
+ const type = getAssetType(asset, 'input')
+ const name = asset.name
+ if (name) {
+ if (type === 'output') {
+ const subfolder = getOutputAssetMetadata(asset.user_metadata)?.subfolder
+ const path = subfolder ? `${subfolder}/${name}` : name
+ variants.push(`${path} [output]`)
+ } else if (type === 'temp') {
+ variants.push(`${name} [temp]`)
+ } else {
+ variants.push(name)
+ variants.push(`${name} [input]`)
+ }
+ }
+ if (asset.asset_hash) variants.push(asset.asset_hash)
+ return variants
+}
+
export function useMediaAssetActions() {
const { t } = useI18n()
const toast = useToast()
@@ -639,6 +674,31 @@ export function useMediaAssetActions() {
await assetsStore.updateInputs()
}
+ const rootGraph = app.rootGraph
+ if (rootGraph) {
+ const deletedValues = new Set()
+ assetArray.forEach((asset, index) => {
+ if (results[index].status !== 'fulfilled') return
+ for (const value of widgetValueVariantsForAsset(asset)) {
+ deletedValues.add(value)
+ }
+ })
+ if (deletedValues.size > 0) {
+ const nodeOutputStore = useNodeOutputStore()
+ // Order matters: mark + cache-clear both look up nodes by
+ // current widget.value, so they must run before
+ // clearDeletedAssetWidgetValues blanks those values.
+ markDeletedAssetsAsMissingMedia(rootGraph, deletedValues)
+ clearNodePreviewCacheForValues(
+ rootGraph,
+ deletedValues,
+ (node) => nodeOutputStore.removeNodeOutputsForNode(node)
+ )
+ clearDeletedAssetWidgetValues(rootGraph, deletedValues)
+ useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
+ }
+ }
+
// Invalidate model caches for affected categories
const modelCategories = new Set()
diff --git a/src/platform/assets/mappings/modelNodeMappings.ts b/src/platform/assets/mappings/modelNodeMappings.ts
index fd2d60f590..0368c7ca52 100644
--- a/src/platform/assets/mappings/modelNodeMappings.ts
+++ b/src/platform/assets/mappings/modelNodeMappings.ts
@@ -52,9 +52,6 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
// ---- SAM3 3D segmentation (comfyui-sam3) ----
['sam3', 'LoadSAM3Model', 'model_path'],
- // ---- Ultralytics detection (comfyui-impact-subpack) ----
- ['ultralytics', 'UltralyticsDetectorProvider', 'model_name'],
-
// ---- DepthAnything (comfyui-depthanythingv2, comfyui-depthanythingv3) ----
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
['depthanything3', 'DownloadAndLoadDepthAnythingV3Model', 'model'],
diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts
index bd31113009..0f7017fea7 100644
--- a/src/platform/assets/schemas/assetSchema.ts
+++ b/src/platform/assets/schemas/assetSchema.ts
@@ -1,3 +1,4 @@
+import { zListAssetsResponse } from '@comfyorg/ingest-types/zod'
import { z } from 'zod'
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
@@ -20,11 +21,11 @@ const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
-const zAssetResponse = z.object({
- assets: z.array(zAsset).optional(),
- total: z.number().optional(),
- has_more: z.boolean().optional()
-})
+const zAssetResponse = zListAssetsResponse
+ .pick({ total: true, has_more: true })
+ .extend({
+ assets: z.array(zAsset)
+ })
const zModelFolder = z.object({
name: z.string(),
diff --git a/src/platform/assets/services/assetService.test.ts b/src/platform/assets/services/assetService.test.ts
index e718cb3d72..b073bcce71 100644
--- a/src/platform/assets/services/assetService.test.ts
+++ b/src/platform/assets/services/assetService.test.ts
@@ -1,11 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import type {
+ AssetItem,
+ AssetResponse
+} from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
- assetService,
- isBlake3AssetHash,
- toBlake3AssetHash
+ assetService
} from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
@@ -49,9 +50,10 @@ vi.mock('@/i18n', () => ({
const fetchApiMock = vi.mocked(api.fetchApi)
-const validBlake3Hash =
- '1111111111111111111111111111111111111111111111111111111111111111'
-const validBlake3AssetHash = `blake3:${validBlake3Hash}`
+type AssetListResponseOptions = {
+ hasMore?: AssetResponse['has_more']
+ total?: AssetResponse['total']
+}
function buildResponse(
body: unknown,
@@ -64,6 +66,13 @@ function buildResponse(
} as unknown as Response
}
+function buildAssetListResponse(
+ assets: AssetItem[],
+ { hasMore = false, total = assets.length }: AssetListResponseOptions = {}
+): Response {
+ return buildResponse({ assets, total, has_more: hasMore })
+}
+
function validAsset(overrides: Partial = {}): AssetItem {
return {
id: 'asset-1',
@@ -189,25 +198,6 @@ describe(assetService.getAssetMetadata, () => {
})
})
-describe(isBlake3AssetHash, () => {
- it('accepts only prefixed 64-character blake3 hashes', () => {
- expect(isBlake3AssetHash(validBlake3AssetHash)).toBe(true)
- expect(isBlake3AssetHash('BLAKE3:' + validBlake3Hash.toUpperCase())).toBe(
- true
- )
- expect(isBlake3AssetHash('blake3:abc')).toBe(false)
- expect(isBlake3AssetHash(validBlake3Hash)).toBe(false)
- })
-})
-
-describe(toBlake3AssetHash, () => {
- it('normalizes 64-character blake3 hex values to asset hashes', () => {
- expect(toBlake3AssetHash(validBlake3Hash)).toBe(validBlake3AssetHash)
- expect(toBlake3AssetHash('abc')).toBeNull()
- expect(toBlake3AssetHash(undefined)).toBeNull()
- })
-})
-
describe(assetService.uploadAssetFromUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -218,7 +208,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -240,7 +230,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
)
@@ -301,7 +291,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -327,7 +317,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse({
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
@@ -421,17 +411,14 @@ describe(assetService.getAssetModelFolders, () => {
vi.clearAllMocks()
})
- it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => {
+ it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
fetchApiMock.mockResolvedValueOnce(
- buildResponse({
- assets: [
- validAsset({ id: 'a', tags: ['models', 'loras'] }),
- validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
- validAsset({ id: 'c', tags: ['models', 'configs'] }),
- validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
- validAsset({ id: 'e', tags: ['models', 'loras'] })
- ]
- })
+ buildAssetListResponse([
+ validAsset({ id: 'a', tags: ['models', 'loras'] }),
+ validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
+ validAsset({ id: 'c', tags: ['models', 'configs'] }),
+ validAsset({ id: 'e', tags: ['models', 'loras'] })
+ ])
)
const folders = await assetService.getAssetModelFolders()
@@ -444,6 +431,7 @@ describe(assetService.getAssetModelFolders, () => {
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.has('include_public')).toBe(false)
+ expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
})
@@ -490,14 +478,9 @@ describe(assetService.getAssetsByTag, () => {
vi.clearAllMocks()
})
- it('forwards include_public=true by default and excludes missing-tagged assets', async () => {
+ it('forwards include_public=true by default and requests missing-tag exclusion', async () => {
fetchApiMock.mockResolvedValueOnce(
- buildResponse({
- assets: [
- validAsset({ id: 'visible', tags: ['input'] }),
- validAsset({ id: 'hidden', tags: ['input', 'missing'] })
- ]
- })
+ buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
)
const assets = await assetService.getAssetsByTag('input')
@@ -507,6 +490,20 @@ describe(assetService.getAssetsByTag, () => {
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_public')).toBe('true')
+ expect(params.get('exclude_tags')).toBe(MISSING_TAG)
+ })
+
+ it('normalizes tag query parameters', async () => {
+ fetchApiMock.mockResolvedValueOnce(
+ buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
+ )
+
+ await assetService.getAssetsByTag(' input ')
+
+ const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
+ const params = new URL(requestedUrl, 'http://localhost').searchParams
+ expect(params.get('include_tags')).toBe('input')
+ expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
})
@@ -518,17 +515,16 @@ describe(assetService.getAllAssetsByTag, () => {
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
- buildResponse({
- assets: [
+ buildAssetListResponse(
+ [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
- ]
- })
+ ],
+ { hasMore: true }
+ )
)
.mockResolvedValueOnce(
- buildResponse({
- assets: [validAsset({ id: 'c', tags: ['input'] })]
- })
+ buildAssetListResponse([validAsset({ id: 'c', tags: ['input'] })])
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -540,63 +536,33 @@ describe(assetService.getAllAssetsByTag, () => {
const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string
const firstParams = new URL(firstUrl, 'http://localhost').searchParams
expect(firstParams.get('include_public')).toBe('true')
+ expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(firstParams.get('limit')).toBe('2')
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('include_public')).toBe('true')
+ expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(secondParams.get('limit')).toBe('2')
expect(secondParams.get('offset')).toBe('2')
})
- it('paginates from raw response size before filtering missing-tagged assets', async () => {
- fetchApiMock
- .mockResolvedValueOnce(
- buildResponse({
- assets: [
- validAsset({ id: 'visible', tags: ['input'] }),
- validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
- ]
- })
- )
- .mockResolvedValueOnce(
- buildResponse({
- assets: [validAsset({ id: 'later-public', tags: ['input'] })]
- })
- )
-
- const assets = await assetService.getAllAssetsByTag('input', true, {
- limit: 2
- })
-
- expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public'])
- expect(fetchApiMock).toHaveBeenCalledTimes(2)
-
- const secondUrl = fetchApiMock.mock.calls[1]?.[0]
- if (typeof secondUrl !== 'string') {
- throw new Error('Expected a second asset request URL')
- }
- const secondParams = new URL(secondUrl, 'http://localhost').searchParams
- expect(secondParams.get('offset')).toBe('2')
- })
-
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
- buildResponse({
- assets: [
+ buildAssetListResponse(
+ [
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
- has_more: true
- })
+ { hasMore: true }
+ )
)
.mockResolvedValueOnce(
- buildResponse({
- assets: [validAsset({ id: 'later-public', tags: ['input'] })],
- has_more: false
- })
+ buildAssetListResponse([
+ validAsset({ id: 'later-public', tags: ['input'] })
+ ])
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -614,12 +580,41 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('offset')).toBe('2')
})
+ it.each([
+ {
+ name: 'missing has_more',
+ body: {
+ assets: [validAsset({ id: 'a', tags: ['input'] })],
+ total: 1
+ }
+ },
+ {
+ name: 'missing total',
+ body: {
+ assets: [validAsset({ id: 'a', tags: ['input'] })],
+ has_more: false
+ }
+ },
+ {
+ name: 'non-boolean has_more',
+ body: {
+ assets: [validAsset({ id: 'a', tags: ['input'] })],
+ total: 1,
+ has_more: 'false'
+ }
+ }
+ ])('rejects asset responses with $name', async ({ body }) => {
+ fetchApiMock.mockResolvedValueOnce(buildResponse(body))
+
+ await expect(
+ assetService.getAllAssetsByTag('input', true, { limit: 2 })
+ ).rejects.toThrow(/Invalid asset response/)
+ })
+
it('passes abort signals through paginated requests', async () => {
const controller = new AbortController()
fetchApiMock.mockResolvedValueOnce(
- buildResponse({
- assets: [validAsset({ id: 'a', tags: ['input'] })]
- })
+ buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })])
)
await assetService.getAllAssetsByTag('input', true, {
@@ -636,12 +631,13 @@ describe(assetService.getAllAssetsByTag, () => {
const controller = new AbortController()
fetchApiMock.mockImplementationOnce(async () => {
controller.abort()
- return buildResponse({
- assets: [
+ return buildAssetListResponse(
+ [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
- ]
- })
+ ],
+ { hasMore: true }
+ )
})
await expect(
@@ -666,7 +662,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
validAsset({ id: 'user-input', tags: ['input'] }),
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
]
- fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
+ fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
const first = await assetService.getInputAssetsIncludingPublic()
const second = await assetService.getInputAssetsIncludingPublic()
@@ -685,8 +681,8 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
- .mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
+ .mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
@@ -720,7 +716,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(serviceSignal).toBeUndefined()
- resolveResponse(buildResponse({ assets }))
+ resolveResponse(buildAssetListResponse(assets))
await expect(second).resolves.toEqual(assets)
expect(fetchApiMock).toHaveBeenCalledOnce()
@@ -750,7 +746,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
- resolveResponse(buildResponse({ assets }))
+ resolveResponse(buildAssetListResponse(assets))
await Promise.resolve()
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -770,12 +766,12 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
resolveResponse = resolve
})
)
- .mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(freshAssets))
const inFlight = assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
- resolveResponse(buildResponse({ assets }))
+ resolveResponse(buildAssetListResponse(assets))
await expect(inFlight).resolves.toEqual(assets)
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -788,9 +784,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(null))
- .mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.deleteAsset('stale-input')
@@ -809,9 +805,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
const freshAssets = [uploadedAsset]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(uploadedAsset))
- .mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
@@ -827,7 +823,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for pending async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'running' },
@@ -849,7 +845,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for non-input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
- .mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
+ .mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
await assetService.getInputAssetsIncludingPublic()
@@ -863,37 +859,3 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
})
-
-describe(assetService.checkAssetHash, () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it.each([
- [200, 'exists'],
- [404, 'missing'],
- [400, 'invalid']
- ] as const)('maps %s responses to %s', async (status, expected) => {
- const hash =
- 'blake3:1111111111111111111111111111111111111111111111111111111111111111'
- fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status }))
-
- await expect(assetService.checkAssetHash(hash)).resolves.toBe(expected)
-
- expect(fetchApiMock).toHaveBeenCalledWith(
- `/assets/hash/${encodeURIComponent(hash)}`,
- {
- method: 'HEAD',
- signal: undefined
- }
- )
- })
-
- it('throws for unexpected responses', async () => {
- fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status: 500 }))
-
- await expect(assetService.checkAssetHash('blake3:abc')).rejects.toThrow(
- 'Unexpected asset hash check status: 500'
- )
- })
-})
diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts
index 65132bace7..3ac5dc2c71 100644
--- a/src/platform/assets/services/assetService.ts
+++ b/src/platform/assets/services/assetService.ts
@@ -36,6 +36,7 @@ interface AssetPaginationOptions extends PaginationOptions {
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
+ excludeTags?: string[]
includePublic?: boolean
signal?: AbortSignal
}
@@ -181,27 +182,12 @@ const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
export const MODELS_TAG = 'models'
/** Asset tag used by the backend for placeholder records that are not installed. */
export const MISSING_TAG = 'missing'
+const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG]
-/** Result of a HEAD lookup against an exact asset hash. */
-export type AssetHashStatus = 'exists' | 'missing' | 'invalid'
-
-const BLAKE3_ASSET_HASH_PATTERN = /^blake3:[0-9a-f]{64}$/i
-const BLAKE3_HEX_PATTERN = /^[0-9a-f]{64}$/i
const uploadedAssetResponseSchema = assetItemSchema.extend({
created_new: z.boolean()
})
-/** Returns true for a prefixed BLAKE3 asset hash: `blake3:<64 hex>`. */
-export function isBlake3AssetHash(value: string): boolean {
- return BLAKE3_ASSET_HASH_PATTERN.test(value)
-}
-
-/** Converts a raw 64-character BLAKE3 hex digest into an asset hash. */
-export function toBlake3AssetHash(hash: string | undefined): string | null {
- if (!hash || !BLAKE3_HEX_PATTERN.test(hash)) return null
- return `blake3:${hash}`
-}
-
function createAbortError(): DOMException {
return new DOMException('Aborted', 'AbortError')
}
@@ -210,6 +196,10 @@ function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw createAbortError()
}
+function normalizeAssetTags(tags: string[]): string[] {
+ return tags.map((tag) => tag.trim()).filter(Boolean)
+}
+
async function withCallerAbort(
promise: Promise,
signal?: AbortSignal
@@ -290,15 +280,22 @@ function createAssetService() {
): Promise {
const {
includeTags,
+ excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
limit = DEFAULT_LIMIT,
offset,
includePublic,
signal
} = options
+ const normalizedIncludeTags = normalizeAssetTags(includeTags)
+ const normalizedExcludeTags = normalizeAssetTags(excludeTags)
+
const queryParams = new URLSearchParams({
- include_tags: includeTags.join(','),
+ include_tags: normalizedIncludeTags.join(','),
limit: limit.toString()
})
+ if (normalizedExcludeTags.length > 0) {
+ queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
+ }
if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
@@ -337,15 +334,10 @@ function createAssetService() {
// Blacklist directories we don't want to show
const blacklistedDirectories = new Set(['configs'])
- // Extract directory names from assets that actually exist, exclude missing assets
- const discoveredFolders = new Set(
- data?.assets
- ?.filter((asset) => !asset.tags.includes(MISSING_TAG))
- ?.flatMap((asset) => asset.tags)
- ?.filter(
- (tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
- ) ?? []
- )
+ const folderTags = data.assets
+ .flatMap((asset) => asset.tags)
+ .filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
+ const discoveredFolders = new Set(folderTags)
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).toSorted()
@@ -363,17 +355,10 @@ function createAssetService() {
`models for ${folder}`
)
- return (
- data?.assets
- ?.filter(
- (asset) =>
- !asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
- )
- ?.map((asset) => ({
- name: asset.name,
- pathIndex: 0
- })) ?? []
- )
+ return data.assets.map((asset) => ({
+ name: asset.name,
+ pathIndex: 0
+ }))
}
/**
@@ -449,12 +434,7 @@ function createAssetService() {
)
// Return full AssetItem[] objects (don't strip like getAssetModels does)
- return (
- data?.assets?.filter(
- (asset) =>
- !asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
- ) ?? []
- )
+ return data.assets
}
/**
@@ -473,11 +453,8 @@ function createAssetService() {
}
const data = await res.json()
- // Validate the single asset response against our schema
- const result = assetResponseSchema.safeParse({ assets: [data] })
- if (result.success && result.data.assets?.[0]) {
- return result.data.assets[0]
- }
+ const result = assetItemSchema.safeParse(data)
+ if (result.success) return result.data
const error = result.error
? fromZodError(result.error)
@@ -503,18 +480,32 @@ function createAssetService() {
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
): Promise {
- const data = await handleAssetRequest(
+ const data = await getAssetsPageByTag(tag, includePublic, {
+ limit,
+ offset,
+ signal
+ })
+
+ return data.assets
+ }
+
+ /**
+ * Gets one paginated asset response filtered by a specific tag.
+ */
+ async function getAssetsPageByTag(
+ tag: string,
+ includePublic: boolean = true,
+ { limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
+ ): Promise {
+ return await handleAssetRequest(
{ includeTags: [tag], limit, offset, includePublic, signal },
`assets for tag ${tag}`
)
-
- return (
- data?.assets?.filter((asset) => !asset.tags.includes(MISSING_TAG)) ?? []
- )
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
+ * Pagination follows the required server-provided `has_more` flag.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
@@ -535,23 +526,19 @@ function createAssetService() {
while (true) {
if (signal?.aborted) throw createAbortError()
- const data = await handleAssetRequest(
- {
- includeTags: [tag],
- limit: pageSize,
- offset,
- includePublic,
- signal
- },
- `assets for tag ${tag}`
- )
- const batch = data.assets ?? []
- assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
+ const data = await getAssetsPageByTag(tag, includePublic, {
+ limit: pageSize,
+ offset,
+ signal
+ })
+ const batch = data.assets
+ if (batch.length === 0) {
+ return assets
+ }
- const noMoreFromServer = data.has_more === false
- const inferredLastPage =
- data.has_more === undefined && batch.length < pageSize
- if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
+ assets.push(...batch)
+
+ if (!data.has_more) {
return assets
}
@@ -598,31 +585,6 @@ function createAssetService() {
return await withCallerAbort(request, signal)
}
- /**
- * Checks whether an asset exists for an exact asset hash.
- *
- * Uses the HEAD /assets/hash/{hash} endpoint and maps status-only responses:
- * 200 -> exists, 404 -> missing, and 400 -> invalid hash format.
- */
- async function checkAssetHash(
- assetHash: string,
- signal?: AbortSignal
- ): Promise {
- const response = await api.fetchApi(
- `${ASSETS_ENDPOINT}/hash/${encodeURIComponent(assetHash)}`,
- {
- method: 'HEAD',
- signal
- }
- )
-
- if (response.status === 200) return 'exists'
- if (response.status === 404) return 'missing'
- if (response.status === 400) return 'invalid'
-
- throw new Error(`Unexpected asset hash check status: ${response.status}`)
- }
-
/**
* Deletes an asset by ID
* Only available in cloud environment
@@ -983,10 +945,10 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
+ getAssetsPageByTag,
getAllAssetsByTag,
getInputAssetsIncludingPublic,
invalidateInputAssetsIncludingPublic,
- checkAssetHash,
deleteAsset,
updateAsset,
addAssetTags,
diff --git a/src/platform/assets/utils/assetPreviewUtil.test.ts b/src/platform/assets/utils/assetPreviewUtil.test.ts
index 738ef256a8..07c71a9b99 100644
--- a/src/platform/assets/utils/assetPreviewUtil.test.ts
+++ b/src/platform/assets/utils/assetPreviewUtil.test.ts
@@ -15,6 +15,7 @@ const mockGetServerFeature = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
const mockUploadAssetFromBase64 = vi.hoisted(() => vi.fn())
const mockUpdateAsset = vi.hoisted(() => vi.fn())
+const mockSetAssetPreview = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
@@ -33,6 +34,10 @@ vi.mock('@/platform/assets/services/assetService', () => ({
}
}))
+vi.mock('@/stores/assetsStore', () => ({
+ useAssetsStore: () => ({ setAssetPreview: mockSetAssetPreview })
+}))
+
function mockFetchResponse(assets: Record[]) {
mockFetchApi.mockResolvedValueOnce({
ok: true,
@@ -264,4 +269,66 @@ describe('persistThumbnail', () => {
preview_id: 'new-preview-id'
})
})
+
+ it('patches the assets store by name with the new preview after upload', async () => {
+ mockFetchEmpty()
+ mockFetchResponse([localAsset])
+ mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
+ mockUpdateAsset.mockResolvedValue({})
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('ComfyUI_00081_.glb', blob)
+
+ expect(mockSetAssetPreview).toHaveBeenCalledWith(
+ localAsset.name,
+ 'new-preview-id',
+ 'http://localhost:8188/assets/new-preview-id/content'
+ )
+ })
+
+ it('uses the cloud asset name (not the hash) when patching the store', async () => {
+ mockFetchResponse([cloudAsset])
+ mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
+ mockUpdateAsset.mockResolvedValue({})
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('c6cadcee57dd.glb', blob)
+
+ expect(mockSetAssetPreview).toHaveBeenCalledWith(
+ cloudAsset.name,
+ 'new-preview-id',
+ 'http://localhost:8188/assets/new-preview-id/content'
+ )
+ })
+
+ it('does not patch the store when the asset already has a preview', async () => {
+ mockFetchEmpty()
+ mockFetchResponse([localAssetWithPreview])
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('ComfyUI_00081_.glb', blob)
+
+ expect(mockSetAssetPreview).not.toHaveBeenCalled()
+ })
+
+ it('does not patch the store when no asset is found', async () => {
+ mockFetchEmpty()
+ mockFetchEmpty()
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('nonexistent.glb', blob)
+
+ expect(mockSetAssetPreview).not.toHaveBeenCalled()
+ })
+
+ it('does not patch the store when upload fails', async () => {
+ mockFetchEmpty()
+ mockFetchResponse([localAsset])
+ mockUploadAssetFromBase64.mockRejectedValue(new Error('upload failed'))
+
+ const blob = new Blob(['fake-png'], { type: 'image/png' })
+ await persistThumbnail('ComfyUI_00081_.glb', blob)
+
+ expect(mockSetAssetPreview).not.toHaveBeenCalled()
+ })
})
diff --git a/src/platform/assets/utils/assetPreviewUtil.ts b/src/platform/assets/utils/assetPreviewUtil.ts
index c9fbcce9ec..bdb604abac 100644
--- a/src/platform/assets/utils/assetPreviewUtil.ts
+++ b/src/platform/assets/utils/assetPreviewUtil.ts
@@ -1,5 +1,6 @@
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
+import { useAssetsStore } from '@/stores/assetsStore'
interface AssetRecord {
id: string
@@ -80,6 +81,9 @@ export async function persistThumbnail(
await assetService.updateAsset(asset.id, {
preview_id: uploaded.id
})
+
+ const previewUrl = api.apiURL(`/assets/${uploaded.id}/content`)
+ useAssetsStore().setAssetPreview(asset.name, uploaded.id, previewUrl)
} catch {
// Non-critical — client still shows the rendered thumbnail
}
diff --git a/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts b/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
new file mode 100644
index 0000000000..239654eaa9
--- /dev/null
+++ b/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+
+import { clearDeletedAssetWidgetValues } from './clearDeletedAssetWidgetValues'
+
+type MockWidget = {
+ name: string
+ value: unknown
+ callback?: (value: unknown) => void
+}
+type MockNode = {
+ id: number
+ widgets?: MockWidget[]
+ graph?: { setDirtyCanvas: (v: boolean) => void }
+ isSubgraphNode?: () => boolean
+ subgraph?: { nodes: MockNode[] }
+}
+
+function makeGraph(nodes: MockNode[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 clearDeletedAssetWidgetValues', () => {
+ it('clears widget.value and invokes widget.callback so consumers run their own change-handling', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 1,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe('')
+ expect(callback).toHaveBeenCalledWith('')
+ expect(setDirty).toHaveBeenCalledWith(true)
+ })
+
+ it('leaves untouched widgets that do not match deleted values', () => {
+ const matchedCallback = vi.fn()
+ const keptCallback = vi.fn()
+ const node: MockNode = {
+ id: 2,
+ widgets: [
+ {
+ name: 'image',
+ value: 'outputs/foo.png [output]',
+ callback: matchedCallback
+ },
+ { name: 'mask', value: 'inputs/keep.png', callback: keptCallback }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe('')
+ expect(node.widgets![1].value).toBe('inputs/keep.png')
+ expect(matchedCallback).toHaveBeenCalledWith('')
+ expect(keptCallback).not.toHaveBeenCalled()
+ })
+
+ it('leaves nodes alone when none of their widgets reference a deleted value (mask-editor case)', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 3,
+ widgets: [
+ {
+ name: 'image',
+ value: 'clipspace/clipspace-painted-masked-1.png [input]',
+ callback
+ }
+ ],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/some-other-asset.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe(
+ 'clipspace/clipspace-painted-masked-1.png [input]'
+ )
+ expect(callback).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('no-ops when the deleted-values set is empty', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 4,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(makeGraph([node]), new Set())
+
+ expect(node.widgets![0].value).toBe('outputs/foo.png [output]')
+ expect(callback).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('handles widgets without a callback (legacy nodes) without throwing', () => {
+ const node: MockNode = {
+ id: 5,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ expect(() =>
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+ ).not.toThrow()
+
+ expect(node.widgets![0].value).toBe('')
+ })
+
+ it('clears all matching widgets across multiple nodes', () => {
+ const cbA = vi.fn()
+ const cbB = vi.fn()
+ const nodeA: MockNode = {
+ id: 6,
+ widgets: [
+ { name: 'image', value: 'outputs/a.png [output]', callback: cbA }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const nodeB: MockNode = {
+ id: 7,
+ widgets: [
+ { name: 'image', value: 'outputs/a.png [output]', callback: cbB }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([nodeA, nodeB]),
+ new Set(['outputs/a.png [output]'])
+ )
+
+ expect(nodeA.widgets![0].value).toBe('')
+ expect(nodeB.widgets![0].value).toBe('')
+ expect(cbA).toHaveBeenCalledWith('')
+ expect(cbB).toHaveBeenCalledWith('')
+ })
+
+ it('does not affect nodes without widgets', () => {
+ const node: MockNode = {
+ id: 8,
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ expect(() =>
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+ ).not.toThrow()
+ })
+})
diff --git a/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts b/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
new file mode 100644
index 0000000000..daf3ba6299
--- /dev/null
+++ b/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
@@ -0,0 +1,40 @@
+import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
+
+import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
+
+/**
+ * Clear widget values that reference deleted assets so the persisted workflow
+ * JSON stops claiming the deleted asset is in use.
+ *
+ * Without this, after `useMediaAssetActions.deleteAssets` succeeds the
+ * in-memory preview is cleared (`clearNodePreviewCacheForValues`) but the
+ * widget value still points at the deleted asset. On reload the workflow JSON
+ * is restored verbatim and `useImageUploadWidget` re-fetches the URL — for
+ * output assets the file is still served (history-soft-delete), so the
+ * preview re-renders despite the asset being "deleted" everywhere else.
+ *
+ * Mutates `widget.value` (which `LGraphNode.serialize` re-reads to rebuild
+ * `widgets_values`) and invokes `widget.callback` so widgets like Load Image
+ * run their own change-handling (clearing `node.imgs`, calling
+ * `setNodeOutputs`, etc.).
+ *
+ * FE-230 — covers the post-reload case without re-introducing
+ * useMissingMediaPreviewSync, which couldn't distinguish deletion from
+ * verification false-positives (e.g. mask-editor saved values).
+ */
+export function clearDeletedAssetWidgetValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet
+): void {
+ if (deletedValues.size === 0) return
+ for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
+ if (!node.widgets) continue
+ for (const widget of node.widgets) {
+ if (typeof widget.value !== 'string') continue
+ if (!deletedValues.has(widget.value)) continue
+ widget.value = ''
+ widget.callback?.('')
+ }
+ node.graph?.setDirtyCanvas(true)
+ }
+}
diff --git a/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts b/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
new file mode 100644
index 0000000000..4d772db7f2
--- /dev/null
+++ b/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
+
+import {
+ clearNodePreviewCacheForValues,
+ findNodesReferencingValues
+} from './clearNodePreviewCacheForValues'
+
+type MockWidget = { name: string; value: unknown }
+type MockNode = {
+ id: number
+ widgets?: MockWidget[]
+ imgs?: unknown
+ videoContainer?: unknown
+ graph?: { setDirtyCanvas: (v: boolean) => void }
+ isSubgraphNode?: () => boolean
+ subgraph?: { nodes: MockNode[] }
+}
+
+function makeGraph(nodes: MockNode[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 clearNodePreviewCacheForValues', () => {
+ it('clears node.imgs and removes outputs when a widget value matches a deleted value', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 7,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ expect(setDirty).toHaveBeenCalledWith(true)
+ })
+
+ it('leaves unrelated nodes untouched', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 8,
+ widgets: [{ name: 'image', value: 'unrelated.png' }],
+ imgs: [{ src: 'blob:keep' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toEqual([{ src: 'blob:keep' }])
+ expect(remove).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('no-ops when the deleted value set is empty', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 9,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:keep' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toEqual([{ src: 'blob:keep' }])
+ expect(remove).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('matches the [output]-annotated form for output assets', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 12,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('matches the subfolder-prefixed annotated form when provided', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 13,
+ widgets: [{ name: 'image', value: 'sub/foo.png [output]' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['sub/foo.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('does not cross-match basenames across input/output sources', () => {
+ const remove = vi.fn()
+ const inputNode: MockNode = {
+ id: 1,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:input' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const outputNode: MockNode = {
+ id: 2,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }],
+ imgs: [{ src: 'blob:output' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([inputNode, outputNode]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(inputNode.imgs).toBeUndefined()
+ expect(outputNode.imgs).toEqual([{ src: 'blob:output' }])
+ expect(remove).toHaveBeenCalledWith(inputNode)
+ expect(remove).not.toHaveBeenCalledWith(outputNode)
+ })
+
+ it('also clears videoContainer for video previews', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 15,
+ widgets: [{ name: 'video', value: 'clip.mp4' }],
+ videoContainer: { foo: 'bar' },
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['clip.mp4']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.videoContainer).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('matches any widget on the node, not just "image"', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 10,
+ widgets: [
+ { name: 'seed', value: 42 },
+ { name: 'video', value: 'clip.mp4' }
+ ],
+ imgs: [{ src: 'blob:videostale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['clip.mp4']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('walks subgraph interiors and matches nested nodes', () => {
+ const inner: MockNode = {
+ id: 100,
+ widgets: [{ name: 'image', value: 'nested.png [output]' }],
+ imgs: [{ src: 'blob:nested' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const wrapper: MockNode = {
+ id: 50,
+ widgets: [],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+ const remove = vi.fn()
+
+ clearNodePreviewCacheForValues(
+ makeGraph([wrapper]),
+ new Set(['nested.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(inner.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(inner)
+ })
+})
+
+describe('FE-230 findNodesReferencingValues', () => {
+ it('skips subgraph wrapper nodes (only their interior nodes match)', () => {
+ const inner: MockNode = {
+ id: 100,
+ widgets: [{ name: 'image', value: 'foo.png' }]
+ }
+ const wrapper: MockNode = {
+ id: 50,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+
+ const matches = findNodesReferencingValues(
+ makeGraph([wrapper]),
+ new Set(['foo.png'])
+ )
+
+ expect(matches).toEqual([inner])
+ })
+})
diff --git a/src/platform/assets/utils/clearNodePreviewCacheForValues.ts b/src/platform/assets/utils/clearNodePreviewCacheForValues.ts
new file mode 100644
index 0000000000..bbd6d9c5f2
--- /dev/null
+++ b/src/platform/assets/utils/clearNodePreviewCacheForValues.ts
@@ -0,0 +1,65 @@
+import type {
+ LGraph,
+ LGraphNode,
+ Subgraph
+} from '@/lib/litegraph/src/litegraph'
+import { collectAllNodes } from '@/utils/graphTraversalUtil'
+
+/**
+ * Clear cached Load Image / Load Video preview state on any node whose widget
+ * value matches one of the given values. Covers:
+ * - the canvas renderer cache (`node.imgs`, `node.videoContainer`)
+ * - the Vue preview source — must be cleared via `removeOutputsForNode`
+ * so the Pinia reactive ref (`nodeOutputStore.nodeOutputs.value`) updates,
+ * not just the legacy `app.nodeOutputs` mirror
+ *
+ * Comparison is full-string against the widget value as stored — callers must
+ * provide the canonical widget-value variants for each deleted asset (e.g.
+ * `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, ``). This
+ * avoids false matches when two distinct assets share a basename across
+ * input/output sources.
+ *
+ * Walks the full graph hierarchy via `collectAllNodes`, so Load Image / Load
+ * Video nodes inside subgraphs are also matched.
+ *
+ * FE-230 — invoked after successful asset deletion so the Load Image / Load
+ * Video node preview does not keep displaying a thumbnail for an asset that
+ * no longer exists.
+ */
+export function clearNodePreviewCacheForValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet,
+ removeOutputsForNode: (node: LGraphNode) => void
+): void {
+ if (deletedValues.size === 0) return
+ for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
+ removeOutputsForNode(node)
+ node.imgs = undefined
+ node.videoContainer = undefined
+ node.graph?.setDirtyCanvas(true)
+ }
+}
+
+/**
+ * Walk the graph hierarchy and yield each leaf node whose widget value matches
+ * one of `deletedValues`. Used by both the preview-clearing path and the
+ * missing-media-marking path so the two stay in lockstep.
+ *
+ * Skips subgraph wrapper nodes — only their interior nodes are inspected.
+ */
+export function findNodesReferencingValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet
+): LGraphNode[] {
+ if (deletedValues.size === 0) return []
+ const matches: LGraphNode[] = []
+ for (const node of collectAllNodes(rootGraph)) {
+ if (!node.widgets?.length) continue
+ if (node.isSubgraphNode?.()) continue
+ const referencesDeleted = node.widgets.some(
+ (w) => typeof w.value === 'string' && deletedValues.has(w.value)
+ )
+ if (referencesDeleted) matches.push(node)
+ }
+ return matches
+}
diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts
new file mode 100644
index 0000000000..705d65499e
--- /dev/null
+++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts
@@ -0,0 +1,185 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+
+import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia'
+
+vi.mock('@/platform/distribution/types', () => ({
+ isCloud: true
+}))
+
+const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/missingMedia/missingMediaScan', () => ({
+ scanNodeMediaCandidates: mockScanNodeMediaCandidates
+}))
+
+vi.mock('@/renderer/core/canvas/canvasStore', () => ({
+ useCanvasStore: () => ({ currentGraph: null })
+}))
+
+function makeGraph(nodes: unknown[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 markDeletedAssetsAsMissingMedia', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ mockScanNodeMediaCandidates.mockReset()
+ mockScanNodeMediaCandidates.mockReturnValue([])
+ })
+
+ it('adds missing-media candidates only for widgets whose value is in the deleted set', () => {
+ const node = {
+ id: 1,
+ type: 'LoadImage',
+ widgets: [
+ { name: 'image', value: 'sub/foo.png [output]' },
+ { name: 'mask', value: 'unrelated.png' }
+ ]
+ }
+ mockScanNodeMediaCandidates.mockReturnValue([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'sub/foo.png [output]'
+ },
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'mask',
+ mediaType: 'image',
+ name: 'unrelated.png'
+ }
+ ])
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([node]),
+ new Set(['sub/foo.png [output]'])
+ )
+
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toEqual([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'sub/foo.png [output]',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('does not cross-match basenames across input/output sources', () => {
+ const inputNode = {
+ id: 2,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'foo.png' }]
+ }
+ const outputNode = {
+ id: 3,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([inputNode, outputNode]),
+ new Set(['foo.png'])
+ )
+
+ expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1)
+ expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith(
+ expect.anything(),
+ inputNode,
+ true
+ )
+ })
+
+ it('skips nodes with NEVER or BYPASS mode', () => {
+ const bypassed = {
+ id: 4,
+ type: 'LoadImage',
+ mode: 4,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+ const never = {
+ id: 5,
+ type: 'LoadImage',
+ mode: 2,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([bypassed, never]),
+ new Set(['foo.png [output]'])
+ )
+
+ expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+
+ it('walks subgraph interiors and marks nested nodes', () => {
+ const inner = {
+ id: 100,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'nested.png [output]' }]
+ }
+ const wrapper = {
+ id: 50,
+ widgets: [],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+ mockScanNodeMediaCandidates.mockReturnValue([
+ {
+ nodeId: '50:100',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'nested.png [output]'
+ }
+ ])
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([wrapper]),
+ new Set(['nested.png [output]'])
+ )
+
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toEqual([
+ {
+ nodeId: '50:100',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'nested.png [output]',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('is a no-op when no nodes reference any deleted value', () => {
+ const node = {
+ id: 2,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'kept.png' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(makeGraph([node]), new Set(['gone.png']))
+
+ expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+
+ it('does nothing when the deleted value set is empty', () => {
+ markDeletedAssetsAsMissingMedia(makeGraph([]), new Set())
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+})
diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
new file mode 100644
index 0000000000..800e685147
--- /dev/null
+++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
@@ -0,0 +1,50 @@
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
+import { isCloud } from '@/platform/distribution/types'
+import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
+
+import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
+
+/**
+ * After a successful asset deletion, surface the affected Load Image / Load
+ * Video / Load Audio nodes through the missing-media store. Without this, UI
+ * surfaces that filter against `missingMediaCandidates` (e.g. the Vue node
+ * widget dropdown) keep listing the deleted asset because the verification
+ * pipeline only runs on workflow load — there is no signal that the live
+ * deletion just invalidated some references.
+ *
+ * Walks the full graph hierarchy (including subgraphs) and skips bypassed /
+ * never-execute nodes, mirroring `scanAllMediaCandidates` so the live-delete
+ * path stays in lockstep with the workflow-load verification.
+ *
+ * Comparison is full-string against the widget value, so two distinct assets
+ * that share a basename across input/output sources do not cross-match.
+ */
+export function markDeletedAssetsAsMissingMedia(
+ rootGraph: LGraph,
+ deletedValues: ReadonlySet
+): void {
+ if (deletedValues.size === 0) return
+
+ const matchedNodes = findNodesReferencingValues(rootGraph, deletedValues)
+ if (!matchedNodes.length) return
+
+ const candidates: MissingMediaCandidate[] = []
+ for (const node of matchedNodes) {
+ if (
+ node.mode === LGraphEventMode.NEVER ||
+ node.mode === LGraphEventMode.BYPASS
+ )
+ continue
+ for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) {
+ if (!deletedValues.has(candidate.name)) continue
+ candidates.push({ ...candidate, isMissing: true })
+ }
+ }
+
+ if (candidates.length) {
+ useMissingMediaStore().addMissingMedia(candidates)
+ }
+}
diff --git a/src/platform/cloud/onboarding/CloudLoginView.vue b/src/platform/cloud/onboarding/CloudLoginView.vue
index 969227bca1..ce0ec8f790 100644
--- a/src/platform/cloud/onboarding/CloudLoginView.vue
+++ b/src/platform/cloud/onboarding/CloudLoginView.vue
@@ -7,7 +7,7 @@
{{ t('auth.login.title') }}
-
+
{{
freeTierCredits
? t('auth.login.freeTierDescription', {
@@ -86,7 +89,11 @@
class="text-sm underline"
@click="switchToSocialLogin"
>
- {{ t('auth.login.backToSocialLogin') }}
+ {{
+ googleSsoBlockedReason
+ ? t('auth.login.backToGithubLogin')
+ : t('auth.login.backToSocialLogin')
+ }}
diff --git a/src/platform/missingMedia/mediaPathDetectionUtil.test.ts b/src/platform/missingMedia/mediaPathDetectionUtil.test.ts
new file mode 100644
index 0000000000..d9a1dba670
--- /dev/null
+++ b/src/platform/missingMedia/mediaPathDetectionUtil.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ getAnnotatedMediaPathTypeForDetection,
+ getMediaPathDetectionNames,
+ normalizeAnnotatedMediaPathForDetection
+} from './mediaPathDetectionUtil'
+
+describe('normalizeAnnotatedMediaPathForDetection', () => {
+ it.each([
+ ['photo.png [input]', 'photo.png'],
+ ['result.png [output]', 'result.png'],
+ ['photo.png [input]', 'photo.png'],
+ ['with spaces.png [output]', 'with spaces.png'],
+ ['nested/folder/video.mp4 [output]', 'nested/folder/video.mp4']
+ ])('strips Core-style annotation from %s', (value, expected) => {
+ expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(expected)
+ })
+
+ it.each([
+ ['photo.png[input]', 'photo.png'],
+ ['result.png[output]', 'result.png'],
+ ['with spaces.png [output]', 'with spaces.png']
+ ])('strips Cloud compact annotation from %s', (value, expected) => {
+ expect(
+ normalizeAnnotatedMediaPathForDetection(value, {
+ allowCompactSuffix: true
+ })
+ ).toBe(expected)
+ })
+
+ it('does not strip compact annotations in Core mode', () => {
+ expect(normalizeAnnotatedMediaPathForDetection('photo.png[input]')).toBe(
+ 'photo.png[input]'
+ )
+ })
+
+ it.each(['photo.png [draft]', 'photo [output] copy.png', 'photo.png', ''])(
+ 'leaves non-matching values unchanged: %s',
+ (value) => {
+ expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(value)
+ }
+ )
+})
+
+describe('getMediaPathDetectionNames', () => {
+ it('returns raw and normalized names when an annotation is stripped', () => {
+ expect(getMediaPathDetectionNames('photo.png [input]')).toEqual([
+ 'photo.png [input]',
+ 'photo.png'
+ ])
+ })
+
+ it('returns only the raw name when no annotation is stripped', () => {
+ expect(getMediaPathDetectionNames('photo.png')).toEqual(['photo.png'])
+ })
+})
+
+describe('getAnnotatedMediaPathTypeForDetection', () => {
+ it.each([
+ ['photo.png [input]', 'input'],
+ ['photo.png [output]', 'output']
+ ])('returns the Core-style annotation type from %s', (value, expected) => {
+ expect(getAnnotatedMediaPathTypeForDetection(value)).toBe(expected)
+ })
+
+ it('returns the compact annotation type in Cloud mode', () => {
+ expect(
+ getAnnotatedMediaPathTypeForDetection('photo.png[output]', {
+ allowCompactSuffix: true
+ })
+ ).toBe('output')
+ })
+
+ it('returns undefined when no supported annotation is present', () => {
+ expect(getAnnotatedMediaPathTypeForDetection('photo.png [draft]')).toBe(
+ undefined
+ )
+ })
+})
diff --git a/src/platform/missingMedia/mediaPathDetectionUtil.ts b/src/platform/missingMedia/mediaPathDetectionUtil.ts
new file mode 100644
index 0000000000..2e27311f08
--- /dev/null
+++ b/src/platform/missingMedia/mediaPathDetectionUtil.ts
@@ -0,0 +1,44 @@
+// Missing-media-scoped helpers for deriving comparison keys from media widget paths.
+const CORE_ANNOTATED_MEDIA_PATTERN = /\s+\[(input|output)\]$/
+const CLOUD_ANNOTATED_MEDIA_PATTERN = /\s*\[(input|output)\]$/
+
+type AnnotatedMediaPathType = 'input' | 'output'
+
+interface AnnotatedMediaPathOptions {
+ allowCompactSuffix?: boolean
+}
+
+function getAnnotatedMediaPathMatch(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): RegExpMatchArray | null {
+ const pattern = options.allowCompactSuffix
+ ? CLOUD_ANNOTATED_MEDIA_PATTERN
+ : CORE_ANNOTATED_MEDIA_PATTERN
+ return value.match(pattern)
+}
+
+export function getAnnotatedMediaPathTypeForDetection(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): AnnotatedMediaPathType | undefined {
+ return getAnnotatedMediaPathMatch(value, options)?.[1] as
+ | AnnotatedMediaPathType
+ | undefined
+}
+
+export function normalizeAnnotatedMediaPathForDetection(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): string {
+ const match = getAnnotatedMediaPathMatch(value, options)
+ return match ? value.slice(0, match.index) : value
+}
+
+export function getMediaPathDetectionNames(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): string[] {
+ const normalized = normalizeAnnotatedMediaPathForDetection(value, options)
+ return normalized === value ? [value] : [value, normalized]
+}
diff --git a/src/platform/missingMedia/missingMediaAssetResolver.test.ts b/src/platform/missingMedia/missingMediaAssetResolver.test.ts
new file mode 100644
index 0000000000..c6eee64c47
--- /dev/null
+++ b/src/platform/missingMedia/missingMediaAssetResolver.test.ts
@@ -0,0 +1,325 @@
+import { fromAny } from '@total-typescript/shoehorn'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import type * as AssetServiceModule from '@/platform/assets/services/assetService'
+import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import {
+ getAssetDetectionNames,
+ resolveMissingMediaAssetSources
+} from './missingMediaAssetResolver'
+
+const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
+ vi.hoisted(() => ({
+ mockGetInputAssetsIncludingPublic: vi.fn(),
+ mockGetAssetsPageByTag: vi.fn()
+ }))
+
+const { mockFetchHistoryPage } = vi.hoisted(() => ({
+ mockFetchHistoryPage: vi.fn()
+}))
+
+vi.mock('@/platform/assets/services/assetService', async () => {
+ const actual = await vi.importActual
(
+ '@/platform/assets/services/assetService'
+ )
+
+ return {
+ ...actual,
+ assetService: {
+ ...actual.assetService,
+ getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
+ getAssetsPageByTag: mockGetAssetsPageByTag
+ }
+ }
+})
+
+vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
+ const actual = await vi.importActual(
+ '@/platform/remote/comfyui/jobs/fetchJobs'
+ )
+
+ return {
+ ...actual,
+ fetchHistoryPage: mockFetchHistoryPage
+ }
+})
+
+function makeAsset(name: string, assetHash: string | null = null): AssetItem {
+ return {
+ id: name,
+ name,
+ asset_hash: assetHash,
+ mime_type: null,
+ tags: ['input']
+ }
+}
+
+function makeHistoryJob(
+ filename: string,
+ options: { id?: string; subfolder?: string } = {}
+): JobListItem {
+ return fromAny({
+ id: options.id ?? filename,
+ status: 'completed',
+ create_time: 0,
+ priority: 0,
+ preview_output: {
+ filename,
+ subfolder: options.subfolder ?? '',
+ type: 'output',
+ nodeId: '1',
+ mediaType: 'images'
+ }
+ })
+}
+
+function makeHistoryPage(
+ jobs: JobListItem[],
+ options: { offset?: number; hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ jobs,
+ total: options.total ?? jobs.length,
+ offset: options.offset ?? 0,
+ limit: 200,
+ hasMore: options.hasMore ?? false
+ }
+}
+
+function makeAssetPage(
+ assets: AssetItem[],
+ options: { hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ assets,
+ total: options.total ?? assets.length,
+ has_more: options.hasMore ?? false
+ }
+}
+
+describe('resolveMissingMediaAssetSources', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([])
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
+ mockFetchHistoryPage.mockResolvedValue(makeHistoryPage([]))
+ })
+
+ it('loads cloud input assets when requested', async () => {
+ const inputAsset = makeAsset('photo.png')
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([inputAsset])
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: true
+ })
+
+ expect(result.inputAssets).toEqual([inputAsset])
+ expect(result.generatedAssets).toEqual([])
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('loads cloud output assets by tag when generated candidates need verification', async () => {
+ const outputAsset = makeAsset('output.png')
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([outputAsset]))
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['output.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([outputAsset])
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
+ 'output',
+ true,
+ expect.objectContaining({
+ limit: 500,
+ offset: 0,
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('stops reading cloud output asset pages once all requested names are found', async () => {
+ const target = 'target-output.png'
+ mockGetAssetsPageByTag.mockResolvedValueOnce(
+ makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([makeAsset(target)])
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
+ })
+
+ it('aborts cloud output asset loading when input asset loading fails', async () => {
+ const inputError = new Error('input failed')
+ let rejectInputAssets!: (err: Error) => void
+ let resolveOutputAssets!: (page: ReturnType) => void
+ mockGetInputAssetsIncludingPublic.mockReturnValueOnce(
+ new Promise((_, reject) => {
+ rejectInputAssets = reject
+ })
+ )
+ mockGetAssetsPageByTag.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveOutputAssets = resolve
+ })
+ )
+
+ const promise = resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['target.png']),
+ allowCompactSuffix: true
+ })
+
+ await Promise.resolve()
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
+
+ rejectInputAssets(inputError)
+ await expect(promise).rejects.toBe(inputError)
+
+ resolveOutputAssets(makeAssetPage([makeAsset('other.png')]))
+ await Promise.resolve()
+
+ const outputSignal = mockGetAssetsPageByTag.mock.calls[0]?.[2]?.signal
+ expect(outputSignal).toBeInstanceOf(AbortSignal)
+ expect(outputSignal.aborted).toBe(true)
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('stops reading generated history once all requested names are found', async () => {
+ const target = 'target.png'
+ mockFetchHistoryPage.mockResolvedValueOnce(
+ makeHistoryPage([makeHistoryJob(target)], {
+ hasMore: true,
+ total: 400
+ })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toHaveLength(1)
+ expect(result.generatedAssets[0].name).toBe(target)
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ })
+
+ it('advances pagination from the requested offset, not the echoed offset', async () => {
+ const target = 'target.png'
+ mockFetchHistoryPage
+ .mockResolvedValueOnce(
+ makeHistoryPage(
+ Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ { offset: 0, hasMore: true, total: 201 }
+ )
+ )
+ .mockResolvedValueOnce(
+ makeHistoryPage([makeHistoryJob(target)], {
+ offset: 0,
+ hasMore: true,
+ total: 201
+ })
+ )
+
+ await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 1,
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 2,
+ expect.any(Function),
+ 200,
+ 200
+ )
+ })
+
+ it('stops if history reports hasMore but returns an empty page', async () => {
+ mockFetchHistoryPage.mockResolvedValueOnce(
+ makeHistoryPage([], { hasMore: true, total: 1 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['missing.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([])
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ })
+
+ it('stops if history repeats the same job page', async () => {
+ const repeatedJob = makeHistoryJob('other.png', { id: 'same-job' })
+ mockFetchHistoryPage
+ .mockResolvedValueOnce(
+ makeHistoryPage([repeatedJob], { hasMore: true, total: 2 })
+ )
+ .mockResolvedValueOnce(
+ makeHistoryPage([repeatedJob], { offset: 1, hasMore: true, total: 2 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['missing.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toHaveLength(1)
+ expect(mockFetchHistoryPage).toHaveBeenCalledTimes(2)
+ })
+
+ it('includes slash and backslash subfolder identifiers for detection', () => {
+ const names = getAssetDetectionNames(
+ {
+ ...makeAsset('child\\photo.png', 'hash.png'),
+ user_metadata: { subfolder: 'nested\\folder' }
+ },
+ { allowCompactSuffix: true }
+ )
+
+ expect(names).toEqual(
+ expect.arrayContaining([
+ 'child\\photo.png',
+ 'hash.png',
+ 'nested/folder/child/photo.png',
+ 'nested\\folder\\child\\photo.png'
+ ])
+ )
+ expect(names).not.toContain('nested/folder/hash.png')
+ expect(names).not.toContain('nested\\folder\\hash.png')
+ })
+})
diff --git a/src/platform/missingMedia/missingMediaAssetResolver.ts b/src/platform/missingMedia/missingMediaAssetResolver.ts
new file mode 100644
index 0000000000..00732f8dc5
--- /dev/null
+++ b/src/platform/missingMedia/missingMediaAssetResolver.ts
@@ -0,0 +1,286 @@
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { assetService } from '@/platform/assets/services/assetService'
+import { fetchHistoryPage } from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import { api } from '@/scripts/api'
+import { getFilePathSeparatorVariants, joinFilePath } from '@/utils/formatUtil'
+import { getMediaPathDetectionNames } from './mediaPathDetectionUtil'
+
+const HISTORY_MEDIA_ASSETS_PAGE_SIZE = 200
+const CLOUD_OUTPUT_ASSETS_PAGE_SIZE = 500
+
+interface MediaPathDetectionOptions {
+ allowCompactSuffix: boolean
+}
+
+export interface MissingMediaAssetSources {
+ inputAssets: AssetItem[]
+ generatedAssets: AssetItem[]
+}
+
+export interface ResolveMissingMediaAssetSourcesOptions {
+ signal?: AbortSignal
+ isCloud: boolean
+ includeGeneratedAssets: boolean
+ generatedMatchNames: ReadonlySet
+ allowCompactSuffix: boolean
+}
+
+export type MissingMediaAssetResolver = (
+ options: ResolveMissingMediaAssetSourcesOptions
+) => Promise
+
+export async function resolveMissingMediaAssetSources({
+ signal,
+ isCloud,
+ includeGeneratedAssets,
+ generatedMatchNames,
+ allowCompactSuffix
+}: ResolveMissingMediaAssetSourcesOptions): Promise {
+ const pathOptions = { allowCompactSuffix }
+
+ const controller = new AbortController()
+ const abortFromCaller = () => controller.abort(signal?.reason)
+ if (signal?.aborted) {
+ abortFromCaller()
+ } else {
+ signal?.addEventListener('abort', abortFromCaller, { once: true })
+ }
+
+ try {
+ const [inputAssets, generatedAssets] = await Promise.all([
+ abortSiblingsOnFailure(
+ isCloud
+ ? assetService.getInputAssetsIncludingPublic(controller.signal)
+ : Promise.resolve([]),
+ controller
+ ),
+ abortSiblingsOnFailure(
+ includeGeneratedAssets
+ ? fetchGeneratedAssets(controller.signal, {
+ isCloud,
+ generatedMatchNames,
+ pathOptions
+ })
+ : Promise.resolve([]),
+ controller
+ )
+ ])
+
+ return { inputAssets, generatedAssets }
+ } finally {
+ signal?.removeEventListener('abort', abortFromCaller)
+ }
+}
+
+interface FetchGeneratedAssetsOptions {
+ isCloud: boolean
+ generatedMatchNames: ReadonlySet
+ pathOptions: MediaPathDetectionOptions
+}
+
+export function getAssetDetectionNames(
+ asset: AssetItem,
+ options: MediaPathDetectionOptions
+): string[] {
+ const names = new Set()
+ // Treat names and hashes as opaque match keys because Cloud may use either in widget values.
+ addPathDetectionNames(names, asset.asset_hash, options)
+ addPathDetectionNames(names, asset.name, options)
+
+ const subfolder = asset.user_metadata?.subfolder
+ if (typeof subfolder === 'string' && subfolder) {
+ addSubfolderPathDetectionNames(names, subfolder, asset.name, options)
+ }
+
+ return Array.from(names)
+}
+
+async function fetchGeneratedAssets(
+ signal: AbortSignal | undefined,
+ { isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
+): Promise {
+ if (isCloud) {
+ return await fetchCloudGeneratedAssets(
+ signal,
+ generatedMatchNames,
+ pathOptions
+ )
+ }
+
+ return await fetchGeneratedHistoryAssets(
+ signal,
+ generatedMatchNames,
+ pathOptions
+ )
+}
+
+async function fetchCloudGeneratedAssets(
+ signal: AbortSignal | undefined,
+ targetNames: ReadonlySet,
+ pathOptions: MediaPathDetectionOptions
+): Promise {
+ const assets: AssetItem[] = []
+ const foundTargetNames = new Set()
+ let offset = 0
+
+ while (true) {
+ signal?.throwIfAborted()
+
+ const assetPage = await assetService.getAssetsPageByTag('output', true, {
+ limit: CLOUD_OUTPUT_ASSETS_PAGE_SIZE,
+ offset,
+ signal
+ })
+
+ signal?.throwIfAborted()
+
+ const batch = assetPage.assets
+ if (batch.length === 0) return assets
+
+ for (const asset of batch) {
+ assets.push(asset)
+ rememberResolvedTargetNames(
+ asset,
+ targetNames,
+ foundTargetNames,
+ pathOptions
+ )
+ }
+
+ if (
+ !assetPage.has_more ||
+ hasResolvedAllTargetNames(targetNames, foundTargetNames)
+ ) {
+ return assets
+ }
+
+ offset += batch.length
+ }
+}
+
+async function fetchGeneratedHistoryAssets(
+ signal: AbortSignal | undefined,
+ targetNames: ReadonlySet,
+ pathOptions: MediaPathDetectionOptions
+): Promise {
+ const assets: AssetItem[] = []
+ const foundTargetNames = new Set()
+ const seenJobIds = new Set()
+ let offset = 0
+
+ while (true) {
+ signal?.throwIfAborted()
+
+ const requestedOffset = offset
+ const historyPage = await fetchHistoryPage(
+ api.fetchApi.bind(api),
+ HISTORY_MEDIA_ASSETS_PAGE_SIZE,
+ requestedOffset
+ )
+
+ signal?.throwIfAborted()
+
+ let newJobCount = 0
+ for (const job of historyPage.jobs) {
+ if (seenJobIds.has(job.id)) continue
+ seenJobIds.add(job.id)
+ newJobCount += 1
+
+ const asset = mapHistoryJobToAsset(job)
+ if (!asset) continue
+
+ assets.push(asset)
+ rememberResolvedTargetNames(
+ asset,
+ targetNames,
+ foundTargetNames,
+ pathOptions
+ )
+ }
+
+ if (
+ !historyPage.hasMore ||
+ historyPage.jobs.length === 0 ||
+ newJobCount === 0 ||
+ hasResolvedAllTargetNames(targetNames, foundTargetNames)
+ ) {
+ return assets
+ }
+
+ offset = requestedOffset + historyPage.jobs.length
+ }
+}
+
+async function abortSiblingsOnFailure(
+ promise: Promise,
+ controller: AbortController
+): Promise {
+ try {
+ return await promise
+ } catch (err) {
+ if (!controller.signal.aborted) controller.abort(err)
+ throw err
+ }
+}
+
+function addPathDetectionNames(
+ names: Set,
+ value: string | null | undefined,
+ options: MediaPathDetectionOptions
+) {
+ if (!value) return
+ for (const name of getMediaPathDetectionNames(value, options)) {
+ names.add(name)
+ }
+}
+
+function addSubfolderPathDetectionNames(
+ names: Set,
+ subfolder: string,
+ value: string | null | undefined,
+ options: MediaPathDetectionOptions
+) {
+ if (!value) return
+
+ const filePath = joinFilePath(subfolder, value)
+ for (const path of getFilePathSeparatorVariants(filePath)) {
+ addPathDetectionNames(names, path, options)
+ }
+}
+
+function rememberResolvedTargetNames(
+ asset: AssetItem,
+ targetNames: ReadonlySet,
+ foundTargetNames: Set,
+ options: MediaPathDetectionOptions
+) {
+ if (targetNames.size === 0) return
+
+ for (const name of getAssetDetectionNames(asset, options)) {
+ if (targetNames.has(name)) foundTargetNames.add(name)
+ }
+}
+
+function hasResolvedAllTargetNames(
+ targetNames: ReadonlySet,
+ foundTargetNames: ReadonlySet
+): boolean {
+ return targetNames.size > 0 && foundTargetNames.size === targetNames.size
+}
+
+function mapHistoryJobToAsset(job: JobListItem): AssetItem | null {
+ const output = job.preview_output
+ if (job.status !== 'completed' || !output?.filename) return null
+
+ return {
+ id: `${job.id}-${output.filename}`,
+ name: output.filename,
+ display_name: output.display_name,
+ mime_type: null,
+ tags: ['output'],
+ user_metadata: {
+ subfolder: output.subfolder
+ }
+ }
+}
diff --git a/src/platform/missingMedia/missingMediaScan.test.ts b/src/platform/missingMedia/missingMediaScan.test.ts
index 8e77aae88c..78073743bc 100644
--- a/src/platform/missingMedia/missingMediaScan.test.ts
+++ b/src/platform/missingMedia/missingMediaScan.test.ts
@@ -6,21 +6,27 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
+import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
import {
scanAllMediaCandidates,
scanNodeMediaCandidates,
- verifyCloudMediaCandidates,
+ verifyMediaCandidates,
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
-const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted(
- () => ({
- mockCheckAssetHash: vi.fn(),
- mockGetInputAssetsIncludingPublic: vi.fn()
- })
-)
+const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
+ vi.hoisted(() => ({
+ mockGetInputAssetsIncludingPublic: vi.fn(),
+ mockGetAssetsPageByTag: vi.fn()
+ }))
+
+const { mockFetchHistoryPage } = vi.hoisted(() => ({
+ mockFetchHistoryPage: vi.fn()
+}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
@@ -39,12 +45,23 @@ vi.mock('@/platform/assets/services/assetService', async () => {
...actual,
assetService: {
...actual.assetService,
- checkAssetHash: mockCheckAssetHash,
- getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
+ getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
+ getAssetsPageByTag: mockGetAssetsPageByTag
}
}
})
+vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
+ const actual = await vi.importActual(
+ '@/platform/remote/comfyui/jobs/fetchJobs'
+ )
+
+ return {
+ ...actual,
+ fetchHistoryPage: mockFetchHistoryPage
+ }
+})
+
function makeCandidate(
nodeId: string,
name: string,
@@ -104,6 +121,43 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
}
}
+function makeAssetResolver(
+ inputAssets: AssetItem[],
+ generatedAssets: AssetItem[] = []
+): MissingMediaAssetResolver {
+ return vi.fn(async () => ({ inputAssets, generatedAssets }))
+}
+
+function makeAssetPage(
+ assets: AssetItem[],
+ options: { hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ assets,
+ total: options.total ?? assets.length,
+ has_more: options.hasMore ?? false
+ }
+}
+
+function makeHistoryJob(
+ filename: string,
+ options: { id?: string; subfolder?: string } = {}
+): JobListItem {
+ return fromAny({
+ id: options.id ?? filename,
+ status: 'completed',
+ create_time: 0,
+ priority: 0,
+ preview_output: {
+ filename,
+ subfolder: options.subfolder ?? '',
+ type: 'output',
+ nodeId: '1',
+ mediaType: 'images'
+ }
+ })
+}
+
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
const graph = makeGraph([])
@@ -149,6 +203,131 @@ describe('scanNodeMediaCandidates', () => {
expect(result).toEqual([])
})
+
+ it.each([
+ {
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ value: 'photo.png [input]',
+ option: 'photo.png'
+ },
+ {
+ nodeType: 'LoadImageMask',
+ widgetName: 'image',
+ mediaType: 'image',
+ value: 'mask.png [input]',
+ option: 'mask.png'
+ },
+ {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ mediaType: 'video',
+ value: 'clip.mp4 [input]',
+ option: 'clip.mp4'
+ },
+ {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ mediaType: 'audio',
+ value: 'sound.wav [input]',
+ option: 'sound.wav'
+ }
+ ])(
+ 'matches annotated $nodeType values against clean OSS options',
+ ({ nodeType, widgetName, mediaType, value, option }) => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ nodeType,
+ [makeMediaCombo(widgetName, value, [option])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({
+ nodeType,
+ widgetName,
+ mediaType,
+ name: value,
+ isMissing: false
+ })
+ }
+ )
+
+ it.each([
+ {
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ value: 'photo.png [output]'
+ },
+ {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ value: 'clip.mp4 [output]'
+ },
+ {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ value: 'sound.wav [output]'
+ }
+ ])(
+ 'leaves OSS $nodeType output annotations pending when not in options',
+ ({ nodeType, widgetName, value }) => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ nodeType,
+ [makeMediaCombo(widgetName, value, ['other-file.png', value])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ nodeType,
+ widgetName,
+ name: value,
+ isMissing: undefined
+ })
+ }
+ )
+
+ it('marks OSS input annotations missing when the clean option is absent', () => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadImage',
+ [makeMediaCombo('image', 'photo.png [input]', ['other.png'])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ name: 'photo.png [input]',
+ isMissing: true
+ })
+ })
+
+ it('does not treat compact Cloud annotations as valid OSS options', () => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadImage',
+ [makeMediaCombo('image', 'photo.png[input]', ['photo.png'])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ name: 'photo.png[input]',
+ isMissing: true
+ })
+ })
})
describe('scanAllMediaCandidates', () => {
@@ -265,7 +444,7 @@ describe('groupCandidatesByMediaType', () => {
})
})
-describe('verifyCloudMediaCandidates', () => {
+describe('verifyMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
const missingHash =
@@ -273,36 +452,355 @@ describe('verifyCloudMediaCandidates', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockCheckAssetHash.mockResolvedValue('missing')
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
+ mockFetchHistoryPage.mockResolvedValue({
+ jobs: [],
+ total: 0,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
})
- it('marks candidates missing when the asset hash is not found', async () => {
+ it('matches candidates by available input asset name or hash', async () => {
const candidates = [
- makeCandidate('1', missingHash, { isMissing: undefined }),
- makeCandidate('2', existingHash, { isMissing: undefined })
+ makeCandidate('1', 'photo.png', { isMissing: undefined }),
+ makeCandidate('2', existingHash, { isMissing: undefined }),
+ makeCandidate('3', missingHash, { isMissing: undefined })
]
+ const resolveAssetSources = makeAssetResolver([
+ makeAsset('photo.png', existingHash)
+ ])
- const checkAssetHash = vi.fn(async (assetHash: string) =>
- assetHash === existingHash ? ('exists' as const) : ('missing' as const)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(false)
+ expect(candidates[1].isMissing).toBe(false)
+ expect(candidates[2].isMissing).toBe(true)
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: true,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: true
+ })
+ })
+
+ it('matches asset names when asset_hash is null', async () => {
+ const candidates = [
+ makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
+ makeCandidate('2', 'missing-photo.png', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([
+ makeAsset('legacy-photo.png', null)
+ ])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(false)
+ expect(candidates[1].isMissing).toBe(true)
+ })
+
+ it('matches annotated candidate names against clean asset names', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png [input]', { isMissing: undefined }),
+ makeCandidate('2', 'clip.mp4[input]', {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ mediaType: 'video',
+ isMissing: undefined
+ }),
+ makeCandidate('3', 'missing.wav [output]', {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ mediaType: 'audio',
+ isMissing: undefined
+ })
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [makeAsset('photo.png'), makeAsset('clip.mp4')],
+ []
)
- await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
- expect(candidates[0].isMissing).toBe(true)
- expect(candidates[1].isMissing).toBe(false)
+ expect(candidates[0]).toMatchObject({
+ name: 'photo.png [input]',
+ isMissing: false
+ })
+ expect(candidates[1]).toMatchObject({
+ name: 'clip.mp4[input]',
+ isMissing: false
+ })
+ expect(candidates[2]).toMatchObject({
+ name: 'missing.wav [output]',
+ isMissing: true
+ })
})
- it('uses assetService.checkAssetHash by default', async () => {
+ it('matches output hash filenames against generated media assets', async () => {
+ const candidates = [
+ makeCandidate(
+ '1',
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
+ {
+ isMissing: undefined
+ }
+ )
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [],
+ [
+ makeAsset(
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ )
+ ]
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ ]),
+ allowCompactSuffix: true
+ })
+ expect(candidates[0]).toMatchObject({
+ name: '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
+ isMissing: false
+ })
+ })
+
+ it('does not satisfy output annotations with input assets of the same name', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png [output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('does not satisfy input candidates with output assets of the same name', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([], [makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('verifies OSS output candidates against generated history without cloud assets', async () => {
+ const candidates = [
+ makeCandidate('1', 'subfolder/photo.png [output]', {
+ isMissing: undefined
+ })
+ ]
+
+ mockFetchHistoryPage.mockResolvedValueOnce({
+ jobs: [makeHistoryJob('photo.png', { subfolder: 'subfolder' })],
+ total: 1,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
+ expect(mockFetchHistoryPage).toHaveBeenCalledWith(
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(candidates[0]).toMatchObject({
+ name: 'subfolder/photo.png [output]',
+ isMissing: false
+ })
+ })
+
+ it('does not normalize compact annotations when verifying OSS candidates', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png[output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: false,
+ resolveAssetSources
+ })
+
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: false,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: false
+ })
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('matches when the asset identifier itself is annotated', async () => {
+ const candidates = [
+ makeCandidate('1', 'clip.mp4[output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [],
+ [makeAsset('clip.mp4 [output]')]
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('marks pending candidates missing when no input assets are available', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
+ ]
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources: makeAssetResolver([])
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('uses public input assets by default', async () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
- mockCheckAssetHash.mockResolvedValue('exists')
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([
+ makeAsset('stored-photo.png', existingHash)
+ ])
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
- expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('reads cloud output assets by tag for output candidates', async () => {
+ const outputHash =
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ const candidates = [
+ makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
+ ]
+ mockGetAssetsPageByTag.mockResolvedValue(
+ makeAssetPage([makeAsset(outputHash)])
+ )
+
+ await verifyMediaCandidates(candidates, { isCloud: true })
+
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
+ 'output',
+ true,
+ expect.objectContaining({
+ limit: 500,
+ offset: 0,
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('walks OSS generated history pages until hasMore is false', async () => {
+ const outputHash =
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ const candidates = [
+ makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
+ ]
+ mockFetchHistoryPage
+ .mockResolvedValueOnce({
+ jobs: Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ total: 201,
+ offset: 0,
+ limit: 200,
+ hasMore: true
+ })
+ .mockResolvedValueOnce({
+ jobs: [makeHistoryJob(outputHash)],
+ total: 201,
+ offset: 200,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 1,
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 2,
+ expect.any(Function),
+ 200,
+ 200
+ )
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('trusts OSS history hasMore instead of page length', async () => {
+ const candidates = [
+ makeCandidate('1', 'missing-output.png [output]', {
+ isMissing: undefined
+ })
+ ]
+ mockFetchHistoryPage.mockResolvedValueOnce({
+ jobs: Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ total: 200,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ expect(candidates[0].isMissing).toBe(true)
})
it('respects abort signal before execution', async () => {
@@ -313,27 +811,33 @@ describe('verifyCloudMediaCandidates', () => {
makeCandidate('1', missingHash, { isMissing: undefined })
]
- await verifyCloudMediaCandidates(candidates, controller.signal)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal
+ })
expect(candidates[0].isMissing).toBeUndefined()
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
- it('respects abort signal after hash verification', async () => {
+ it('respects abort signal after loading input assets', async () => {
const controller = new AbortController()
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
- const checkAssetHash = vi.fn(async () => {
+ const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
controller.abort()
- return 'exists' as const
+ return {
+ inputAssets: [makeAsset('stored-photo.png', existingHash)],
+ generatedAssets: []
+ }
})
- await verifyCloudMediaCandidates(
- candidates,
- controller.signal,
- checkAssetHash
- )
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal,
+ resolveAssetSources
+ })
expect(candidates[0].isMissing).toBeUndefined()
})
@@ -341,52 +845,30 @@ describe('verifyCloudMediaCandidates', () => {
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(true)
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
it('skips entirely when no pending candidates', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
- it('falls back to input assets for non-blake3 candidate names', async () => {
- const candidates = [
- makeCandidate('1', 'photo.png', { isMissing: undefined }),
- makeCandidate('2', 'missing.png', { isMissing: undefined })
- ]
- const fetchInputAssets = vi.fn(async () => [
- makeAsset('stored-photo.png', 'photo.png')
- ])
-
- await verifyCloudMediaCandidates(
- candidates,
- undefined,
- undefined,
- fetchInputAssets
- )
-
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
- expect(fetchInputAssets).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(false)
- expect(candidates[1].isMissing).toBe(true)
- })
-
- it('uses public input assets for default legacy fallback', async () => {
+ it('loads public input assets for default verification', async () => {
const candidates = [
makeCandidate('1', 'public-photo.png', { isMissing: undefined })
]
@@ -396,135 +878,62 @@ describe('verifyCloudMediaCandidates', () => {
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
- await verifyCloudMediaCandidates(candidates)
-
- expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
- expect(candidates[0].isMissing).toBe(false)
- })
-
- it('silences aborts while loading legacy fallback input assets', async () => {
- const abortError = new Error('aborted')
- abortError.name = 'AbortError'
- const controller = new AbortController()
- const candidates = [
- makeCandidate('1', 'photo.png', { isMissing: undefined })
- ]
- const fetchInputAssets = vi.fn(async () => {
- controller.abort()
- throw abortError
- })
-
- await expect(
- verifyCloudMediaCandidates(
- candidates,
- controller.signal,
- undefined,
- fetchInputAssets
- )
- ).resolves.toBeUndefined()
-
- expect(candidates[0].isMissing).toBeUndefined()
- })
-
- it('silences aborts from the default legacy fallback input asset store path', async () => {
- const abortError = new Error('aborted')
- abortError.name = 'AbortError'
- const controller = new AbortController()
- const candidates = [
- makeCandidate('1', 'photo.png', { isMissing: undefined })
- ]
- mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
- controller.abort()
- throw abortError
- })
-
- await expect(
- verifyCloudMediaCandidates(candidates, controller.signal)
- ).resolves.toBeUndefined()
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
- controller.signal
+ expect.any(AbortSignal)
)
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('silences aborts while loading input assets', async () => {
+ const abortError = new Error('aborted')
+ abortError.name = 'AbortError'
+ const controller = new AbortController()
+ const candidates = [
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
+ ]
+ const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
+ controller.abort()
+ throw abortError
+ })
+
+ await expect(
+ verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal,
+ resolveAssetSources
+ })
+ ).resolves.toBeUndefined()
+
expect(candidates[0].isMissing).toBeUndefined()
})
- it('falls back to input assets when the hash endpoint returns 400', async () => {
+ it('forwards the signal to the default input asset fetcher and silences aborts', async () => {
+ const abortError = new Error('aborted')
+ abortError.name = 'AbortError'
+ const controller = new AbortController()
const candidates = [
- makeCandidate('1', existingHash, { isMissing: undefined })
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
]
- mockCheckAssetHash.mockResolvedValue('invalid')
- const fetchInputAssets = vi.fn(async () => [
- makeAsset('photo.png', existingHash)
- ])
-
- await verifyCloudMediaCandidates(
- candidates,
- undefined,
- undefined,
- fetchInputAssets
+ let serviceSignal: AbortSignal | undefined
+ mockGetInputAssetsIncludingPublic.mockImplementationOnce(
+ async (signal?: AbortSignal) => {
+ serviceSignal = signal
+ controller.abort()
+ throw abortError
+ }
)
- expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
- expect(fetchInputAssets).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(false)
- })
+ await expect(
+ verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal
+ })
+ ).resolves.toBeUndefined()
- it('falls back to input assets when hash verification fails', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const candidates = [
- makeCandidate('1', existingHash, { isMissing: undefined })
- ]
- const checkAssetHash = vi.fn(async () => {
- throw new Error('network failed')
- })
- const fetchInputAssets = vi.fn(async () => [
- makeAsset('photo.png', existingHash)
- ])
-
- await verifyCloudMediaCandidates(
- candidates,
- undefined,
- checkAssetHash,
- fetchInputAssets
- )
-
- expect(fetchInputAssets).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(false)
- expect(warn).toHaveBeenCalledOnce()
- warn.mockRestore()
- })
-
- it('does not call the hash endpoint for malformed blake3-looking values', async () => {
- const malformedHash = 'blake3:abc'
- const candidates = [
- makeCandidate('1', malformedHash, { isMissing: undefined })
- ]
- const fetchInputAssets = vi.fn(async () => [
- makeAsset('legacy.png', malformedHash)
- ])
-
- await verifyCloudMediaCandidates(
- candidates,
- undefined,
- undefined,
- fetchInputAssets
- )
-
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
- expect(fetchInputAssets).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(false)
- })
-
- it('deduplicates checks for repeated candidate names', async () => {
- const candidates = [
- makeCandidate('1', missingHash, { isMissing: undefined }),
- makeCandidate('2', missingHash, { isMissing: undefined })
- ]
-
- await verifyCloudMediaCandidates(candidates)
-
- expect(mockCheckAssetHash).toHaveBeenCalledOnce()
- expect(candidates[0].isMissing).toBe(true)
- expect(candidates[1].isMissing).toBe(true)
+ expect(serviceSignal).toBeInstanceOf(AbortSignal)
+ expect(serviceSignal?.aborted).toBe(true)
+ expect(candidates[0].isMissing).toBeUndefined()
})
})
diff --git a/src/platform/missingMedia/missingMediaScan.ts b/src/platform/missingMedia/missingMediaScan.ts
index 5050996e06..afbd3bcf27 100644
--- a/src/platform/missingMedia/missingMediaScan.ts
+++ b/src/platform/missingMedia/missingMediaScan.ts
@@ -19,11 +19,17 @@ import {
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
-import type { AssetHashStatus } from '@/platform/assets/services/assetService'
+import { isAbortError } from '@/utils/typeGuardUtil'
import {
- assetService,
- isBlake3AssetHash
-} from '@/platform/assets/services/assetService'
+ getAnnotatedMediaPathTypeForDetection,
+ getMediaPathDetectionNames,
+ normalizeAnnotatedMediaPathForDetection
+} from './mediaPathDetectionUtil'
+import {
+ getAssetDetectionNames,
+ resolveMissingMediaAssetSources
+} from './missingMediaAssetResolver'
+import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
/** Map of node types to their media widget name and media type. */
const MEDIA_NODE_WIDGETS: Record<
@@ -31,6 +37,7 @@ const MEDIA_NODE_WIDGETS: Record<
{ widgetName: string; mediaType: MediaType }
> = {
LoadImage: { widgetName: 'image', mediaType: 'image' },
+ LoadImageMask: { widgetName: 'image', mediaType: 'image' },
LoadVideo: { widgetName: 'file', mediaType: 'video' },
LoadAudio: { widgetName: 'audio', mediaType: 'audio' }
}
@@ -42,7 +49,8 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
/**
* Scan combo widgets on media nodes for file values that may be missing.
*
- * OSS: `isMissing` resolved immediately via widget options.
+ * OSS: `isMissing` is resolved immediately via widget options unless an
+ * output annotation needs generated-history verification.
* Cloud: `isMissing` left `undefined` for async verification.
*/
export function scanAllMediaCandidates(
@@ -95,8 +103,17 @@ export function scanNodeMediaCandidates(
if (isCloud) {
isMissing = undefined
} else {
- const options = resolveComboValues(widget)
- isMissing = !options.includes(value)
+ const type = getAnnotatedMediaPathTypeForDetection(value)
+ if (type === 'output') {
+ isMissing = undefined
+ } else {
+ const options = resolveComboValues(widget)
+ const detectionNames = getMediaPathDetectionNames(value)
+ const existsInOptions = detectionNames.some((name) =>
+ options.includes(name)
+ )
+ isMissing = !existsInOptions
+ }
}
candidates.push({
@@ -112,99 +129,57 @@ export function scanNodeMediaCandidates(
return candidates
}
-type AssetHashVerifier = (
- assetHash: string,
+interface MediaVerificationOptions {
+ isCloud: boolean
signal?: AbortSignal
-) => Promise
-
-type InputAssetFetcher = (signal?: AbortSignal) => Promise
-
-function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
- candidatesByHash: Map
- legacyCandidates: MissingMediaCandidate[]
-} {
- const candidatesByHash = new Map()
- const legacyCandidates: MissingMediaCandidate[] = []
-
- for (const candidate of candidates) {
- if (!isBlake3AssetHash(candidate.name)) {
- legacyCandidates.push(candidate)
- continue
- }
-
- const hashCandidates = candidatesByHash.get(candidate.name)
- if (hashCandidates) hashCandidates.push(candidate)
- else candidatesByHash.set(candidate.name, [candidate])
- }
-
- return { candidatesByHash, legacyCandidates }
-}
-
-async function verifyCandidatesByHash(
- candidatesByHash: Map,
- legacyCandidates: MissingMediaCandidate[],
- signal: AbortSignal | undefined,
- checkAssetHash: AssetHashVerifier
-): Promise {
- await Promise.all(
- Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
- if (signal?.aborted) return
-
- let status: AssetHashStatus
- try {
- status = await checkAssetHash(assetHash, signal)
- if (signal?.aborted) return
- } catch (err) {
- if (signal?.aborted || isAbortError(err)) return
- console.warn(
- '[Missing Media Pipeline] Failed to verify asset hash:',
- err
- )
- legacyCandidates.push(...hashCandidates)
- return
- }
-
- if (status === 'invalid') {
- legacyCandidates.push(...hashCandidates)
- return
- }
-
- for (const candidate of hashCandidates) {
- candidate.isMissing = status === 'missing'
- }
- })
- )
+ resolveAssetSources?: MissingMediaAssetResolver
}
/**
- * Verify cloud media candidates by probing the asset hash endpoint first.
- * Invalid hash values fall back to the legacy input asset list check.
+ * Verify media candidates against assets available to the current runtime.
+ *
+ * A candidate's `name` may be either a filename or an opaque asset hash.
+ * Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
+ * match against the union of `asset.name` and `asset.asset_hash`. Output
+ * candidates are matched against Cloud output assets or Core generated-history
+ * assets because Core resolves those annotations against output folders, not
+ * input files.
+ * Cloud accepts compact annotated media paths, so only Cloud verification
+ * normalizes compact suffixes.
*/
-export async function verifyCloudMediaCandidates(
+export async function verifyMediaCandidates(
candidates: MissingMediaCandidate[],
- signal?: AbortSignal,
- checkAssetHash: AssetHashVerifier = assetService.checkAssetHash,
- fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets
+ {
+ isCloud,
+ signal,
+ resolveAssetSources = resolveMissingMediaAssetSources
+ }: MediaVerificationOptions
): Promise {
if (signal?.aborted) return
const pending = candidates.filter((c) => c.isMissing === undefined)
if (pending.length === 0) return
- const { candidatesByHash, legacyCandidates } =
- groupCandidatesForHashLookup(pending)
- await verifyCandidatesByHash(
- candidatesByHash,
- legacyCandidates,
- signal,
- checkAssetHash
+ // Core stores spaced annotations such as `file.png [output]`; Cloud also
+ // accepts compact forms such as `file.png[output]`.
+ const pathOptions = { allowCompactSuffix: isCloud }
+ const generatedMatchNames = getGeneratedCandidateMatchNames(
+ pending,
+ pathOptions
)
- if (signal?.aborted || legacyCandidates.length === 0) return
-
let inputAssets: AssetItem[]
+ let generatedAssets: AssetItem[]
try {
- inputAssets = await fetchInputAssets(signal)
+ const assetSources = await resolveAssetSources({
+ signal,
+ isCloud,
+ includeGeneratedAssets: generatedMatchNames.size > 0,
+ generatedMatchNames,
+ allowCompactSuffix: isCloud
+ })
+ inputAssets = assetSources.inputAssets
+ generatedAssets = assetSources.generatedAssets
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
throw err
@@ -212,28 +187,62 @@ export async function verifyCloudMediaCandidates(
if (signal?.aborted) return
- const assetHashes = new Set(
- inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
- )
+ const inputAssetIdentifiers = new Set()
+ const outputAssetIdentifiers = new Set()
+ addAssetIdentifiers(inputAssetIdentifiers, inputAssets, pathOptions)
+ addAssetIdentifiers(outputAssetIdentifiers, generatedAssets, pathOptions)
- for (const candidate of legacyCandidates) {
- candidate.isMissing = !assetHashes.has(candidate.name)
+ for (const candidate of pending) {
+ const detectionNames = getMediaPathDetectionNames(
+ candidate.name,
+ pathOptions
+ )
+ const type = getAnnotatedMediaPathTypeForDetection(
+ candidate.name,
+ pathOptions
+ )
+ const identifiers =
+ type === 'output' ? outputAssetIdentifiers : inputAssetIdentifiers
+ candidate.isMissing = !detectionNames.some((name) => identifiers.has(name))
}
}
-async function fetchMissingInputAssets(
- signal?: AbortSignal
-): Promise {
- return await assetService.getInputAssetsIncludingPublic(signal)
+function getGeneratedCandidateMatchNames(
+ candidates: MissingMediaCandidate[],
+ pathOptions: { allowCompactSuffix: boolean }
+): Set {
+ const names = new Set()
+ for (const candidate of candidates) {
+ if (!isGeneratedCandidate(candidate, pathOptions)) continue
+
+ names.add(
+ normalizeAnnotatedMediaPathForDetection(candidate.name, pathOptions)
+ )
+ }
+ return names
}
-function isAbortError(err: unknown): boolean {
- return (
- typeof err === 'object' &&
- err !== null &&
- 'name' in err &&
- err.name === 'AbortError'
+function isGeneratedCandidate(
+ candidate: MissingMediaCandidate,
+ pathOptions: { allowCompactSuffix: boolean }
+): boolean {
+ const type = getAnnotatedMediaPathTypeForDetection(
+ candidate.name,
+ pathOptions
)
+ return type === 'output'
+}
+
+function addAssetIdentifiers(
+ identifiers: Set,
+ assets: AssetItem[],
+ pathOptions: { allowCompactSuffix: boolean }
+) {
+ for (const asset of assets) {
+ for (const name of getAssetDetectionNames(asset, pathOptions)) {
+ identifiers.add(name)
+ }
+ }
}
/** Group confirmed-missing candidates by file name into view models. */
diff --git a/src/platform/missingMedia/types.ts b/src/platform/missingMedia/types.ts
index a07433dc34..8f1f08a69b 100644
--- a/src/platform/missingMedia/types.ts
+++ b/src/platform/missingMedia/types.ts
@@ -16,7 +16,9 @@ export interface MissingMediaCandidate {
/**
* - `true` — confirmed missing
* - `false` — confirmed present
- * - `undefined` — pending async verification (cloud only)
+ * - `undefined` — pending async verification. Cloud candidates start pending;
+ * OSS output annotated paths may also be deferred to generated-history
+ * verification.
*/
isMissing: boolean | undefined
}
diff --git a/src/platform/missingModel/missingModelScan.test.ts b/src/platform/missingModel/missingModelScan.test.ts
index 05326f8bb0..e365718d28 100644
--- a/src/platform/missingModel/missingModelScan.test.ts
+++ b/src/platform/missingModel/missingModelScan.test.ts
@@ -19,11 +19,6 @@ import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/a
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
-import type * as AssetServiceModule from '@/platform/assets/services/assetService'
-
-const { mockCheckAssetHash } = vi.hoisted(() => ({
- mockCheckAssetHash: vi.fn()
-}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
@@ -33,20 +28,6 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
) => node._testExecutionId ?? String(node.id)
}))
-vi.mock('@/platform/assets/services/assetService', async () => {
- const actual = await vi.importActual(
- '@/platform/assets/services/assetService'
- )
-
- return {
- ...actual,
- assetService: {
- ...actual.assetService,
- checkAssetHash: mockCheckAssetHash
- }
- }
-})
-
/** Helper: create a combo widget mock */
function makeComboWidget(
name: string,
@@ -1391,23 +1372,14 @@ describe('OSS missing model detection (non-Cloud path)', () => {
})
})
-const {
- mockUpdateModelsForNodeType,
- mockIsModelLoading,
- mockHasMore,
- mockGetAssets
-} = vi.hoisted(() => ({
+const { mockUpdateModelsForNodeType, mockGetAssets } = vi.hoisted(() => ({
mockUpdateModelsForNodeType: vi.fn().mockResolvedValue(undefined),
- mockIsModelLoading: vi.fn().mockReturnValue(false),
- mockHasMore: vi.fn().mockReturnValue(false),
mockGetAssets: vi.fn().mockReturnValue([])
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
updateModelsForNodeType: mockUpdateModelsForNodeType,
- isModelLoading: mockIsModelLoading,
- hasMore: mockHasMore,
getAssets: mockGetAssets
})
}))
@@ -1440,9 +1412,7 @@ function makeAssetCandidate(
describe('verifyAssetSupportedCandidates', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockCheckAssetHash.mockResolvedValue('missing')
- mockIsModelLoading.mockReturnValue(false)
- mockHasMore.mockReturnValue(false)
+ mockUpdateModelsForNodeType.mockResolvedValue(undefined)
mockGetAssets.mockReturnValue([])
})
@@ -1458,84 +1428,15 @@ describe('verifyAssetSupportedCandidates', () => {
)
})
- it('should resolve isMissing=false when the blake3 hash endpoint finds the asset', async () => {
- const hash =
- '1111111111111111111111111111111111111111111111111111111111111111'
- const candidates = [
- makeAssetCandidate('model.safetensors', {
- hash,
- hashType: 'blake3'
- })
- ]
- mockCheckAssetHash.mockResolvedValue('exists')
-
- await verifyAssetSupportedCandidates(candidates)
-
- expect(candidates[0].isMissing).toBe(false)
- expect(mockCheckAssetHash).toHaveBeenCalledWith(`blake3:${hash}`, undefined)
- expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
- })
-
- it('should fall back to asset store matching when the blake3 hash is not found', async () => {
+ it('should match filenames regardless of hash metadata shape', async () => {
const hash =
'2222222222222222222222222222222222222222222222222222222222222222'
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash,
hashType: 'blake3'
- })
- ]
- mockCheckAssetHash.mockResolvedValue('missing')
- mockGetAssets.mockReturnValue([
- {
- id: '1',
- name: 'my_model.safetensors',
- asset_hash: null,
- metadata: { filename: 'my_model.safetensors' }
- }
- ])
-
- await verifyAssetSupportedCandidates(candidates)
-
- expect(candidates[0].isMissing).toBe(false)
- expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
- 'CheckpointLoaderSimple'
- )
- })
-
- it('should fall back to asset store matching when hash verification fails', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const hash =
- '3333333333333333333333333333333333333333333333333333333333333333'
- const candidates = [
- makeAssetCandidate('my_model.safetensors', {
- hash,
- hashType: 'blake3'
- })
- ]
- mockCheckAssetHash.mockRejectedValue(new Error('network failed'))
- mockGetAssets.mockReturnValue([
- {
- id: '1',
- name: 'my_model.safetensors',
- asset_hash: null,
- metadata: { filename: 'my_model.safetensors' }
- }
- ])
-
- await verifyAssetSupportedCandidates(candidates)
-
- expect(candidates[0].isMissing).toBe(false)
- expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
- 'CheckpointLoaderSimple'
- )
- expect(warn).toHaveBeenCalledOnce()
- warn.mockRestore()
- })
-
- it('should skip malformed blake3 hashes and use asset store matching', async () => {
- const candidates = [
- makeAssetCandidate('my_model.safetensors', {
+ }),
+ makeAssetCandidate('other_model.safetensors', {
hash: 'abc123',
hashType: 'blake3'
})
@@ -1546,38 +1447,25 @@ describe('verifyAssetSupportedCandidates', () => {
name: 'my_model.safetensors',
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
+ },
+ {
+ id: '2',
+ name: 'other_model.safetensors',
+ asset_hash: null,
+ metadata: { filename: 'other_model.safetensors' }
}
])
await verifyAssetSupportedCandidates(candidates)
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(candidates[0].isMissing).toBe(false)
+ expect(candidates[1].isMissing).toBe(false)
+ expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
+ 'CheckpointLoaderSimple'
+ )
})
- it('should not warn or fall back when hash verification is aborted', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const abortError = new Error('aborted')
- abortError.name = 'AbortError'
- const hash =
- '4444444444444444444444444444444444444444444444444444444444444444'
- const candidates = [
- makeAssetCandidate('my_model.safetensors', {
- hash,
- hashType: 'blake3'
- })
- ]
- mockCheckAssetHash.mockRejectedValue(abortError)
-
- await verifyAssetSupportedCandidates(candidates)
-
- expect(candidates[0].isMissing).toBeUndefined()
- expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
- expect(warn).not.toHaveBeenCalled()
- warn.mockRestore()
- })
-
- it('should resolve isMissing=false when asset with matching hash exists', async () => {
+ it('should resolve isMissing=false when asset with matching asset_hash exists', async () => {
const candidates = [
makeAssetCandidate('model.safetensors', {
hash: 'abc123',
@@ -1591,7 +1479,6 @@ describe('verifyAssetSupportedCandidates', () => {
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
- expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('should resolve isMissing=false when asset with matching filename exists', async () => {
@@ -1675,6 +1562,55 @@ describe('verifyAssetSupportedCandidates', () => {
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader')
})
+ it('should leave candidates unresolved when their node type fails to load', async () => {
+ const candidates = [
+ makeAssetCandidate('checkpoint.safetensors', {
+ nodeType: 'CheckpointLoaderSimple'
+ }),
+ makeAssetCandidate('lora.safetensors', { nodeType: 'LoraLoader' })
+ ]
+ mockUpdateModelsForNodeType.mockImplementation(async (nodeType: string) => {
+ if (nodeType === 'LoraLoader') throw new Error('load failed')
+ })
+ mockGetAssets.mockImplementation((nodeType: string) =>
+ nodeType === 'CheckpointLoaderSimple'
+ ? [
+ {
+ id: '1',
+ name: 'checkpoint.safetensors',
+ asset_hash: null,
+ metadata: { filename: 'checkpoint.safetensors' }
+ }
+ ]
+ : []
+ )
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(candidates[0].isMissing).toBe(false)
+ expect(candidates[1].isMissing).toBeUndefined()
+ })
+
+ it('should leave candidates unresolved when aborted after asset loads settle', async () => {
+ const controller = new AbortController()
+ const candidates = [makeAssetCandidate('model.safetensors')]
+ mockUpdateModelsForNodeType.mockImplementation(async () => {
+ controller.abort()
+ })
+ mockGetAssets.mockReturnValue([
+ {
+ id: '1',
+ name: 'model.safetensors',
+ asset_hash: null,
+ metadata: { filename: 'model.safetensors' }
+ }
+ ])
+
+ await verifyAssetSupportedCandidates(candidates, controller.signal)
+
+ expect(candidates[0].isMissing).toBeUndefined()
+ })
+
it('should match filename with path prefix normalization', async () => {
const candidates = [makeAssetCandidate('subfolder/my_model.safetensors')]
mockGetAssets.mockReturnValue([
diff --git a/src/platform/missingModel/missingModelScan.ts b/src/platform/missingModel/missingModelScan.ts
index bef803112a..85f2b6b56c 100644
--- a/src/platform/missingModel/missingModelScan.ts
+++ b/src/platform/missingModel/missingModelScan.ts
@@ -24,11 +24,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
-import type { AssetHashStatus } from '@/platform/assets/services/assetService'
-import {
- assetService,
- toBlake3AssetHash
-} from '@/platform/assets/services/assetService'
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
models?: ModelFile[]
@@ -450,16 +445,10 @@ interface AssetVerifier {
getAssets: (nodeType: string) => AssetItem[] | undefined
}
-type AssetHashVerifier = (
- assetHash: string,
- signal?: AbortSignal
-) => Promise
-
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
- assetsStore?: AssetVerifier,
- checkAssetHash: AssetHashVerifier = assetService.checkAssetHash
+ assetsStore?: AssetVerifier
): Promise {
if (signal?.aborted) return
@@ -468,52 +457,10 @@ export async function verifyAssetSupportedCandidates(
)
if (pendingCandidates.length === 0) return
- const pendingNodeTypes = new Set()
- const candidatesByHash = new Map()
-
- for (const candidate of pendingCandidates) {
- const assetHash = getBlake3AssetHash(candidate)
- if (!assetHash) {
- pendingNodeTypes.add(candidate.nodeType)
- continue
- }
-
- const hashCandidates = candidatesByHash.get(assetHash)
- if (hashCandidates) hashCandidates.push(candidate)
- else candidatesByHash.set(assetHash, [candidate])
- }
-
- await Promise.all(
- Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
- if (signal?.aborted) return
-
- try {
- const status = await checkAssetHash(assetHash, signal)
- if (signal?.aborted) return
-
- if (status === 'exists') {
- for (const candidate of hashCandidates) {
- candidate.isMissing = false
- }
- return
- }
- } catch (err) {
- if (signal?.aborted || isAbortError(err)) return
- console.warn(
- '[Missing Model Pipeline] Failed to verify asset hash:',
- err
- )
- }
-
- for (const candidate of hashCandidates) {
- pendingNodeTypes.add(candidate.nodeType)
- }
- })
+ const pendingNodeTypes = new Set(
+ pendingCandidates.map((candidate) => candidate.nodeType)
)
- if (signal?.aborted) return
- if (pendingNodeTypes.size === 0) return
-
const store =
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
@@ -544,20 +491,6 @@ export async function verifyAssetSupportedCandidates(
}
}
-function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
- if (candidate.hashType?.toLowerCase() !== 'blake3') return null
- return toBlake3AssetHash(candidate.hash)
-}
-
-function isAbortError(err: unknown): boolean {
- return (
- typeof err === 'object' &&
- err !== null &&
- 'name' in err &&
- err.name === 'AbortError'
- )
-}
-
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
index 41b01606e2..53ad431f84 100644
--- a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
extractWorkflow,
fetchHistory,
+ fetchHistoryPage,
fetchJobDetail,
fetchQueue
} from '@/platform/remote/comfyui/jobs/fetchJobs'
@@ -29,15 +30,16 @@ function createMockJob(
function createMockResponse(
jobs: RawJobListItem[],
- total: number = jobs.length
+ total: number = jobs.length,
+ pagination: Partial = {}
): JobsListResponse {
return {
jobs,
pagination: {
- offset: 0,
- limit: 200,
+ offset: pagination.offset ?? 0,
+ limit: pagination.limit ?? 200,
total,
- has_more: false
+ has_more: pagination.has_more ?? false
}
}
}
@@ -100,7 +102,8 @@ describe('fetchJobs', () => {
createMockJob('job4', 'completed'),
createMockJob('job5', 'completed')
],
- 10 // total of 10 jobs
+ 10, // total of 10 jobs
+ { offset: 5 }
)
)
})
@@ -185,6 +188,36 @@ describe('fetchJobs', () => {
expect(result[1].id).toBe('text-job')
expect(result[2].id).toBe('no-preview-job')
})
+
+ it('returns server pagination metadata for history pages', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse(
+ [
+ createMockJob('job4', 'completed'),
+ createMockJob('job5', 'completed')
+ ],
+ 10,
+ { offset: 5, limit: 2, has_more: true }
+ )
+ )
+ })
+
+ const result = await fetchHistoryPage(mockFetch, 2, 5)
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/jobs?status=completed,failed,cancelled&limit=2&offset=5'
+ )
+ expect(result.jobs).toHaveLength(2)
+ expect(result.offset).toBe(5)
+ expect(result.limit).toBe(2)
+ expect(result.total).toBe(10)
+ expect(result.hasMore).toBe(true)
+ expect(result.jobs[0].priority).toBe(5)
+ expect(result.jobs[1].priority).toBe(4)
+ })
})
describe('fetchQueue', () => {
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.ts b/src/platform/remote/comfyui/jobs/fetchJobs.ts
index 6eee0e959c..25790a5ecd 100644
--- a/src/platform/remote/comfyui/jobs/fetchJobs.ts
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.ts
@@ -22,6 +22,16 @@ interface FetchJobsRawResult {
jobs: RawJobListItem[]
total: number
offset: number
+ limit: number
+ hasMore: boolean
+}
+
+export interface FetchHistoryPageResult {
+ jobs: JobListItem[]
+ total: number
+ offset: number
+ limit: number
+ hasMore: boolean
}
/**
@@ -40,13 +50,25 @@ async function fetchJobsRaw(
const res = await fetchApi(url)
if (!res.ok) {
console.error(`[Jobs API] Failed to fetch jobs: ${res.status}`)
- return { jobs: [], total: 0, offset: 0 }
+ return {
+ jobs: [],
+ total: 0,
+ offset,
+ limit: maxItems,
+ hasMore: false
+ }
}
const data = zJobsListResponse.parse(await res.json())
- return { jobs: data.jobs, total: data.pagination.total, offset }
+ return {
+ jobs: data.jobs,
+ total: data.pagination.total,
+ offset: data.pagination.offset,
+ limit: data.pagination.limit,
+ hasMore: data.pagination.has_more
+ }
} catch (error) {
console.error('[Jobs API] Error fetching jobs:', error)
- return { jobs: [], total: 0, offset: 0 }
+ return { jobs: [], total: 0, offset, limit: maxItems, hasMore: false }
}
}
@@ -76,14 +98,33 @@ export async function fetchHistory(
maxItems: number = 200,
offset: number = 0
): Promise {
- const { jobs, total } = await fetchJobsRaw(
+ const { jobs } = await fetchHistoryPage(fetchApi, maxItems, offset)
+ return jobs
+}
+
+/**
+ * Fetches one page of history with server-provided pagination metadata.
+ */
+export async function fetchHistoryPage(
+ fetchApi: (url: string) => Promise,
+ maxItems: number = 200,
+ offset: number = 0
+): Promise {
+ const result = await fetchJobsRaw(
fetchApi,
['completed', 'failed', 'cancelled'],
maxItems,
offset
)
+
// History gets priority based on total count (lower than queue)
- return assignPriority(jobs, total - offset)
+ return {
+ jobs: assignPriority(result.jobs, result.total - result.offset),
+ total: result.total,
+ offset: result.offset,
+ limit: result.limit,
+ hasMore: result.hasMore
+ }
}
/**
diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts
index f805a4e067..4a6a6f79fa 100644
--- a/src/platform/settings/constants/coreSettings.ts
+++ b/src/platform/settings/constants/coreSettings.ts
@@ -659,7 +659,7 @@ export const CORE_SETTINGS: SettingParams[] = [
tooltip:
'The maximum number of tasks added to the queue at one button click',
type: 'number',
- defaultValue: isCloud ? 32 : 100,
+ defaultValue: 100,
versionAdded: '1.3.5'
},
{
diff --git a/src/platform/support/config.test.ts b/src/platform/support/config.test.ts
new file mode 100644
index 0000000000..7720b79e77
--- /dev/null
+++ b/src/platform/support/config.test.ts
@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
+
+vi.mock('@/platform/distribution/types', () => ({
+ get isCloud() {
+ return distribution.isCloud
+ },
+ get isNightly() {
+ return distribution.isNightly
+ }
+}))
+
+describe('buildFeedbackTypeformUrl', () => {
+ beforeEach(() => {
+ distribution.isCloud = false
+ distribution.isNightly = false
+ })
+
+ async function build(source: 'topbar' | 'action-bar' | 'help-center') {
+ vi.resetModules()
+ const { buildFeedbackTypeformUrl } = await import('./config')
+ return buildFeedbackTypeformUrl(source)
+ }
+
+ it('tags Cloud builds with distribution=ccloud', async () => {
+ distribution.isCloud = true
+ expect(await build('topbar')).toBe(
+ 'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar'
+ )
+ })
+
+ it('tags Nightly builds with distribution=oss-nightly', async () => {
+ distribution.isNightly = true
+ expect(await build('action-bar')).toBe(
+ 'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=action-bar'
+ )
+ })
+
+ it('tags OSS builds with distribution=oss', async () => {
+ expect(await build('help-center')).toBe(
+ 'https://form.typeform.com/to/q7azbWPi#distribution=oss&source=help-center'
+ )
+ })
+
+ it('uses a URL fragment so distribution and source are not sent to the server', async () => {
+ distribution.isCloud = true
+ const url = new URL(await build('topbar'))
+ expect(url.search).toBe('')
+ expect(url.hash).toBe('#distribution=ccloud&source=topbar')
+ })
+})
diff --git a/src/platform/support/config.ts b/src/platform/support/config.ts
index 349bfb1ec9..38747ab4d8 100644
--- a/src/platform/support/config.ts
+++ b/src/platform/support/config.ts
@@ -15,7 +15,7 @@ const ZENDESK_FIELDS = {
} as const
/**
- * Gets the distribution identifier for Zendesk tracking.
+ * Gets the distribution identifier for tracking.
* Helps distinguish feedback from different build types.
*/
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
@@ -25,17 +25,22 @@ function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
-const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
+const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
/**
- * Builds the feedback form URL with the appropriate distribution tag.
+ * Builds the feedback Typeform URL tagged with the current build distribution
+ * and the UI source that opened it. Tags are passed via the URL fragment
+ * (Typeform's hidden-field convention) so survey responses can be segmented
+ * by distribution (cloud / oss-nightly / oss) and entry point.
*/
-export function buildFeedbackUrl(): string {
+export function buildFeedbackTypeformUrl(
+ source: 'topbar' | 'action-bar' | 'help-center'
+): string {
const params = new URLSearchParams({
- ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
- [ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
+ distribution: getDistribution(),
+ source
})
- return `${SUPPORT_BASE_URL}?${params.toString()}`
+ return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
}
/**
diff --git a/src/platform/workflow/core/services/workflowService.test.ts b/src/platform/workflow/core/services/workflowService.test.ts
index 73d4a151b2..d5d897b354 100644
--- a/src/platform/workflow/core/services/workflowService.test.ts
+++ b/src/platform/workflow/core/services/workflowService.test.ts
@@ -418,24 +418,51 @@ describe('useWorkflowService', () => {
})
vi.mocked(workflowStore.saveWorkflow).mockResolvedValue()
- await useWorkflowService().saveWorkflow(workflow)
+ const result = await useWorkflowService().saveWorkflow(workflow)
+ expect(result).toBe(true)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
- it('should call saveWorkflowAs for temporary workflows', async () => {
+ it('should return false when temporary workflow save is cancelled', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
- await useWorkflowService().saveWorkflow(workflow)
+ const result = await useWorkflowService().saveWorkflow(workflow)
+ expect(result).toBe(false)
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
})
})
+ describe('closeWorkflow', () => {
+ let workflowStore: ReturnType
+ let service: ReturnType
+
+ beforeEach(() => {
+ workflowStore = useWorkflowStore()
+ service = useWorkflowService()
+ })
+
+ it('keeps a temporary workflow open when Save As is cancelled', async () => {
+ const workflow = createModeTestWorkflow({
+ path: 'workflows/Unsaved Workflow.json'
+ })
+ workflow.isModified = true
+ Object.defineProperty(workflow, 'isTemporary', { get: () => true })
+ vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
+ mockConfirm.mockResolvedValue(true)
+
+ const closed = await service.closeWorkflow(workflow)
+
+ expect(closed).toBe(false)
+ expect(workflowStore.closeWorkflow).not.toHaveBeenCalled()
+ })
+ })
+
describe('afterLoadNewGraph', () => {
let workflowStore: ReturnType
let existingWorkflow: LoadedComfyWorkflow
diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts
index 15dc01879b..c7bc669d1d 100644
--- a/src/platform/workflow/core/services/workflowService.ts
+++ b/src/platform/workflow/core/services/workflowService.ts
@@ -174,40 +174,39 @@ export const useWorkflowService = () => {
* Save a workflow
* @param workflow The workflow to save
*/
- const saveWorkflow = async (workflow: ComfyWorkflow) => {
+ const saveWorkflow = async (workflow: ComfyWorkflow): Promise => {
if (workflow.isTemporary) {
- await saveWorkflowAs(workflow)
- } else {
- workflow.changeTracker?.prepareForSave()
- const isApp = workflow.initialMode === 'app'
- const expectedPath =
- workflow.directory +
- '/' +
- appendWorkflowJsonExt(workflow.filename, isApp)
- if (workflow.path !== expectedPath) {
- const existing = workflowStore.getWorkflowByPath(expectedPath)
- if (existing && !existing.isTemporary) {
- if ((await confirmOverwrite(expectedPath)) !== true) {
- await workflowStore.saveWorkflow(workflow)
- return
- }
- await deleteWorkflow(existing, true)
- }
- await renameWorkflow(workflow, expectedPath)
- toastStore.add({
- severity: 'info',
- summary: t(
- isApp
- ? 'workflowService.savedAsApp'
- : 'workflowService.savedAsWorkflow'
- ),
- life: 3000
- })
- }
-
- await workflowStore.saveWorkflow(workflow)
- useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
+ return await saveWorkflowAs(workflow)
}
+
+ workflow.changeTracker?.prepareForSave()
+ const isApp = workflow.initialMode === 'app'
+ const expectedPath =
+ workflow.directory + '/' + appendWorkflowJsonExt(workflow.filename, isApp)
+ if (workflow.path !== expectedPath) {
+ const existing = workflowStore.getWorkflowByPath(expectedPath)
+ if (existing && !existing.isTemporary) {
+ if ((await confirmOverwrite(expectedPath)) !== true) {
+ await workflowStore.saveWorkflow(workflow)
+ return true
+ }
+ await deleteWorkflow(existing, true)
+ }
+ await renameWorkflow(workflow, expectedPath)
+ toastStore.add({
+ severity: 'info',
+ summary: t(
+ isApp
+ ? 'workflowService.savedAsApp'
+ : 'workflowService.savedAsWorkflow'
+ ),
+ life: 3000
+ })
+ }
+
+ await workflowStore.saveWorkflow(workflow)
+ useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
+ return true
}
/**
@@ -284,13 +283,15 @@ export const useWorkflowService = () => {
type: 'dirtyClose',
message: t('sideToolbar.workflowTab.dirtyClose'),
itemList: [workflow.path],
- hint: options.hint
+ hint: options.hint,
+ denyLabel: t('sideToolbar.workflowTab.dirtyCloseAnyway')
})
// Cancel
if (confirmed === null) return false
if (confirmed === true) {
- await saveWorkflow(workflow)
+ const saved = await saveWorkflow(workflow)
+ if (!saved) return false
}
}
diff --git a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue
index 89195d9cb6..27425ba4ac 100644
--- a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue
+++ b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue
@@ -68,10 +68,16 @@
{{
displayedCredits
}}
-
+ variant="muted-textonly"
+ size="icon-sm"
+ class="mr-auto"
+ :aria-label="$t('credits.unified.tooltip')"
+ data-testid="credits-info-button"
+ >
+
+