mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 14:11:55 +00:00
## Summary
Use exact BLAKE3 hash lookups first for missing model/media detection,
and add a separate public-inclusive input asset cache so public input
assets are considered missing-detection candidates without changing the
user-only input assets shown in the UI.
## Changes
- **What**:
- Added `assetService.checkAssetHash()` for `HEAD
/api/assets/hash/{hash}` status-only existence checks.
- Added strict BLAKE3 hash helpers so only `blake3:<64 hex>` media
values and raw 64-hex BLAKE3 model metadata are sent to the hash
endpoint.
- Updated missing media detection to group BLAKE3 candidates by hash,
resolve them through the hash endpoint, and fall back to the legacy
asset list path for invalid/unverifiable/non-hash values.
- Updated missing model detection to use hash lookup for BLAKE3-backed
asset-supported candidates before falling back to the existing node-type
asset matching path.
- Added `assetService.getInputAssetsIncludingPublic()` backed by a
dedicated cache that fetches input assets with `include_public=true` for
missing media fallback checks.
- Kept `assetsStore.inputAssets` user-only for widget/UI display, while
invalidating the public-inclusive missing-detection cache when input
assets may change.
- Added abort handling for paginated asset fetches and shared
public-input cache callers so one aborted caller does not cancel the
shared fetch for other callers.
- Added regression coverage for hash lookup, fallback behavior, abort
paths, public input fallback detection, and cache invalidation.
- **Dependencies**: None.
- **Change size**:
- Production code: 4 files, 400 insertions, 24 deletions, net +376.
- Test code: 4 files, 806 insertions, 59 deletions, net +747.
- Total: 8 files, 1206 insertions, 83 deletions, net +1123.
## Review Focus
- The public-inclusive input asset cache is intentionally separate from
`assetsStore.inputAssets`. The existing store data is user-only and
drives the asset widgets/sidebar, so using it for missing input
detection misses public assets. Making that store public-inclusive would
change UI data semantics; this PR instead keeps the UI dataset unchanged
and adds a missing-detection-specific cache in `assetService`.
- Hash lookup is only used when the workflow exposes a valid BLAKE3
hash. Filename-like values and invalid hash values still use the legacy
fallback path.
- Missing model detection keeps the existing fallback behavior for
non-hash candidates and for hash checks that are invalid or fail
transiently.
- Async model download cache refresh behavior is left unchanged; this PR
avoids coupling model download completion to input asset cache
invalidation.
- No browser/e2e test was added because this changes the missing asset
detection data path, not UI interaction or rendering. The behavioral
coverage is in unit tests for the asset service and the missing
media/model scanners.
## Follow-up Items
- Fix `assetsStore.updateAssetTags()` partial-failure recovery. If
`removeAssetTags()` succeeds and `addAssetTags()` fails, the local model
asset cache can roll back to tags that the backend has already removed;
this should be handled in a focused model asset cache PR.
- Consider extracting shared hash-verification flow used by missing
media and missing model scans after this behavior stabilizes.
- Consider adding a concurrency cap or short-lived request cache for
large workflows with many unique hash lookups.
- Consider splitting `assetService.ts` further, e.g. hash helpers, abort
utilities, and the public-inclusive input asset cache.
- Consider tightening the asset hash service API shape so callers do not
directly depend on HTTP-oriented statuses such as `invalid`.
- Consider adding broader mutation-path coverage for public-inclusive
input cache invalidation once the cache has more consumers.
Linear: FE-534
## Screenshots (if applicable)
Before <false positive / missing image / public asset>
https://github.com/user-attachments/assets/db7ce2a9-b169-4fae-bf9f-98bb93d3ee6d
After
https://github.com/user-attachments/assets/29af9f9e-b536-4fcd-a426-3add40bcb165
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11873-Use-hash-lookup-for-missing-asset-detection-3556d73d36508165babafb16614be0d8)
by [Unito](https://www.unito.io)
606 lines
18 KiB
TypeScript
606 lines
18 KiB
TypeScript
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
import type { FlattenableWorkflowGraph } from '@/platform/workflow/core/utils/workflowFlattening'
|
|
import { flattenWorkflowNodes } from '@/platform/workflow/core/utils/workflowFlattening'
|
|
import type {
|
|
MissingModelCandidate,
|
|
MissingModelViewModel,
|
|
EmbeddedModelWithSource
|
|
} from './types'
|
|
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|
// eslint-disable-next-line import-x/no-restricted-paths
|
|
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
|
|
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
|
import type {
|
|
IAssetWidget,
|
|
IBaseWidget,
|
|
IComboWidget
|
|
} from '@/lib/litegraph/src/types/widgets'
|
|
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
|
import {
|
|
collectAllNodes,
|
|
getExecutionIdByNode
|
|
} 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[]
|
|
}
|
|
|
|
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
|
return widget.type === 'combo'
|
|
}
|
|
|
|
/**
|
|
* Fills url/hash/directory onto a candidate from the node's embedded
|
|
* `properties.models` metadata when the names match. The full pipeline
|
|
* does this via enrichWithEmbeddedMetadata + graphData.models, but the
|
|
* realtime single-node scan (paste, un-bypass) otherwise loses these
|
|
* fields — making the Missing Model row's download/copy-url buttons
|
|
* disappear after a bypass/un-bypass cycle.
|
|
*/
|
|
function enrichCandidateFromNodeProperties(
|
|
candidate: MissingModelCandidate,
|
|
embeddedModels: readonly ModelFile[] | undefined
|
|
): MissingModelCandidate {
|
|
if (!embeddedModels?.length) return candidate
|
|
// Require directory agreement when the candidate already has one —
|
|
// a single node can reference two models with the same name under
|
|
// different directories (e.g. a LoRA present in multiple folders);
|
|
// name-only matching would stamp the wrong url/hash onto the
|
|
// candidate. Mirrors the directory check in enrichWithEmbeddedMetadata.
|
|
const match = embeddedModels.find(
|
|
(m) =>
|
|
m.name === candidate.name &&
|
|
(!candidate.directory || candidate.directory === m.directory)
|
|
)
|
|
if (!match) return candidate
|
|
return {
|
|
...candidate,
|
|
directory: candidate.directory ?? match.directory,
|
|
url: candidate.url ?? match.url,
|
|
hash: candidate.hash ?? match.hash,
|
|
hashType: candidate.hashType ?? match.hash_type
|
|
}
|
|
}
|
|
|
|
function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
|
return widget.type === 'asset'
|
|
}
|
|
|
|
// Full set of model file extensions used for scanning candidate widgets.
|
|
// Intentionally broader than ALLOWED_SUFFIXES in missingModelDownload.ts,
|
|
// which restricts which files are eligible for download.
|
|
export const MODEL_FILE_EXTENSIONS = new Set([
|
|
'.safetensors',
|
|
'.ckpt',
|
|
'.pt',
|
|
'.pth',
|
|
'.bin',
|
|
'.sft',
|
|
'.onnx',
|
|
'.gguf'
|
|
])
|
|
|
|
export function isModelFileName(name: string): boolean {
|
|
const lower = name.toLowerCase()
|
|
return Array.from(MODEL_FILE_EXTENSIONS).some((ext) => lower.endsWith(ext))
|
|
}
|
|
|
|
/**
|
|
* Scan COMBO and asset widgets on configured graph nodes for model-like values.
|
|
* Must be called after `graph.configure()` so widget name/value mappings are accurate.
|
|
*
|
|
* Non-asset-supported nodes: `isMissing` resolved immediately via widget options.
|
|
* Asset-supported nodes: `isMissing` left `undefined` for async verification.
|
|
*/
|
|
export function scanAllModelCandidates(
|
|
rootGraph: LGraph,
|
|
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
|
getDirectory?: (nodeType: string) => string | undefined
|
|
): MissingModelCandidate[] {
|
|
if (!rootGraph) return []
|
|
|
|
const allNodes = collectAllNodes(rootGraph)
|
|
const candidates: MissingModelCandidate[] = []
|
|
|
|
for (const node of allNodes) {
|
|
if (!node.widgets?.length) continue
|
|
// Skip subgraph container nodes: their promoted widgets are synthetic
|
|
// views of interior widgets, which are already scanned via recursion.
|
|
if (node.isSubgraphNode?.()) continue
|
|
if (
|
|
node.mode === LGraphEventMode.NEVER ||
|
|
node.mode === LGraphEventMode.BYPASS
|
|
)
|
|
continue
|
|
|
|
candidates.push(
|
|
...scanNodeModelCandidates(
|
|
rootGraph,
|
|
node,
|
|
isAssetSupported,
|
|
getDirectory
|
|
)
|
|
)
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
/** Scan a single node's widgets for missing model candidates (OSS immediate resolution). */
|
|
export function scanNodeModelCandidates(
|
|
rootGraph: LGraph,
|
|
node: LGraphNode,
|
|
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
|
getDirectory?: (nodeType: string) => string | undefined
|
|
): MissingModelCandidate[] {
|
|
if (!node.widgets?.length) return []
|
|
|
|
const executionId = getExecutionIdByNode(rootGraph, node)
|
|
if (!executionId) return []
|
|
|
|
const candidates: MissingModelCandidate[] = []
|
|
const embeddedModels = (node as { properties?: { models?: ModelFile[] } })
|
|
.properties?.models
|
|
for (const widget of node.widgets) {
|
|
let candidate: MissingModelCandidate | null = null
|
|
|
|
if (isAssetWidget(widget)) {
|
|
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
|
|
} else if (isComboWidget(widget)) {
|
|
candidate = scanComboWidget(
|
|
node,
|
|
widget,
|
|
executionId,
|
|
isAssetSupported,
|
|
getDirectory
|
|
)
|
|
}
|
|
|
|
if (candidate) {
|
|
candidates.push(
|
|
enrichCandidateFromNodeProperties(candidate, embeddedModels)
|
|
)
|
|
}
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
function scanAssetWidget(
|
|
node: { type: string },
|
|
widget: IAssetWidget,
|
|
executionId: string,
|
|
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
|
): MissingModelCandidate | null {
|
|
const value = widget.value
|
|
if (typeof value !== 'string' || !value.trim()) return null
|
|
if (!isModelFileName(value)) return null
|
|
|
|
return {
|
|
nodeId: executionId,
|
|
nodeType: node.type,
|
|
widgetName: widget.name,
|
|
isAssetSupported: true,
|
|
name: value,
|
|
directory: getDirectory?.(node.type),
|
|
isMissing: undefined
|
|
}
|
|
}
|
|
|
|
function scanComboWidget(
|
|
node: { type: string },
|
|
widget: IComboWidget,
|
|
executionId: string,
|
|
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
|
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
|
): MissingModelCandidate | null {
|
|
const value = widget.value
|
|
if (typeof value !== 'string' || !value.trim()) return null
|
|
if (!isModelFileName(value)) return null
|
|
|
|
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
|
|
const options = resolveComboValues(widget)
|
|
const inOptions = options.includes(value)
|
|
|
|
return {
|
|
nodeId: executionId,
|
|
nodeType: node.type,
|
|
widgetName: widget.name,
|
|
isAssetSupported: nodeIsAssetSupported,
|
|
name: value,
|
|
directory: getDirectory?.(node.type),
|
|
isMissing: nodeIsAssetSupported ? undefined : !inOptions
|
|
}
|
|
}
|
|
|
|
export async function enrichWithEmbeddedMetadata(
|
|
candidates: readonly MissingModelCandidate[],
|
|
graphData: MissingModelWorkflowData,
|
|
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
|
|
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
|
): Promise<MissingModelCandidate[]> {
|
|
const allNodes = flattenWorkflowNodes(graphData)
|
|
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
|
|
|
|
const enriched = candidates.map((c) => ({ ...c }))
|
|
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
|
|
for (const c of enriched) {
|
|
const dirKey = `${c.name}::${c.directory ?? ''}`
|
|
const dirList = candidatesByKey.get(dirKey)
|
|
if (dirList) dirList.push(c)
|
|
else candidatesByKey.set(dirKey, [c])
|
|
|
|
const nameKey = c.name
|
|
const nameList = candidatesByKey.get(nameKey)
|
|
if (nameList) nameList.push(c)
|
|
else candidatesByKey.set(nameKey, [c])
|
|
}
|
|
|
|
const deduped: EmbeddedModelWithSource[] = []
|
|
const enrichedKeys = new Set<string>()
|
|
for (const model of embeddedModels) {
|
|
const dedupeKey = `${model.name}::${model.directory}`
|
|
if (enrichedKeys.has(dedupeKey)) continue
|
|
enrichedKeys.add(dedupeKey)
|
|
deduped.push(model)
|
|
}
|
|
|
|
const unmatched: EmbeddedModelWithSource[] = []
|
|
for (const model of deduped) {
|
|
const dirKey = `${model.name}::${model.directory}`
|
|
const exact = candidatesByKey.get(dirKey)
|
|
const fallback = candidatesByKey.get(model.name)
|
|
const existing = exact?.length ? exact : fallback
|
|
if (existing) {
|
|
for (const c of existing) {
|
|
if (c.directory && c.directory !== model.directory) continue
|
|
c.directory ??= model.directory
|
|
c.url ??= model.url
|
|
c.hash ??= model.hash
|
|
c.hashType ??= model.hash_type
|
|
}
|
|
} else {
|
|
unmatched.push(model)
|
|
}
|
|
}
|
|
|
|
// Workflow-level entries (sourceNodeType === '') survive only when
|
|
// some active (non-muted, non-bypassed) node actually references the
|
|
// model — not merely because any unrelated active node exists. A
|
|
// reference is any widget value (or node.properties.models entry)
|
|
// that matches the model name on an active node.
|
|
// Hoist the id→node map once; isModelReferencedByActiveNode would
|
|
// otherwise rebuild it on every unmatched entry.
|
|
const flattenedNodeById = new Map(allNodes.map((n) => [String(n.id), n]))
|
|
const activeUnmatched = unmatched.filter(
|
|
(m) =>
|
|
m.sourceNodeType !== '' ||
|
|
isModelReferencedByActiveNode(
|
|
m.name,
|
|
m.directory,
|
|
allNodes,
|
|
flattenedNodeById
|
|
)
|
|
)
|
|
|
|
const settled = await Promise.allSettled(
|
|
activeUnmatched.map(async (model) => {
|
|
const installed = await checkModelInstalled(model.name, model.directory)
|
|
if (installed) return null
|
|
|
|
const nodeIsAssetSupported = isAssetSupported
|
|
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
|
|
: false
|
|
|
|
return {
|
|
nodeId: model.sourceNodeId,
|
|
nodeType: model.sourceNodeType,
|
|
widgetName: model.sourceWidgetName,
|
|
isAssetSupported: nodeIsAssetSupported,
|
|
name: model.name,
|
|
directory: model.directory,
|
|
url: model.url,
|
|
hash: model.hash,
|
|
hashType: model.hash_type,
|
|
isMissing: nodeIsAssetSupported ? undefined : true
|
|
} satisfies MissingModelCandidate
|
|
})
|
|
)
|
|
|
|
for (const r of settled) {
|
|
if (r.status === 'rejected') {
|
|
console.warn(
|
|
'[Missing Model Pipeline] checkModelInstalled failed:',
|
|
r.reason
|
|
)
|
|
continue
|
|
}
|
|
if (r.value) enriched.push(r.value)
|
|
}
|
|
|
|
return enriched
|
|
}
|
|
|
|
function isModelReferencedByActiveNode(
|
|
modelName: string,
|
|
modelDirectory: string | undefined,
|
|
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
|
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
|
): boolean {
|
|
for (const node of allNodes) {
|
|
if (
|
|
node.mode === LGraphEventMode.NEVER ||
|
|
node.mode === LGraphEventMode.BYPASS
|
|
)
|
|
continue
|
|
if (!isAncestorPathActiveInFlattened(String(node.id), nodeById)) continue
|
|
|
|
// Require directory agreement when both sides specify one, so a
|
|
// same-name entry under a different folder does not keep an
|
|
// unrelated workflow-level model alive as missing.
|
|
const embeddedModels = (
|
|
node.properties as
|
|
| { models?: Array<{ name: string; directory?: string }> }
|
|
| undefined
|
|
)?.models
|
|
if (
|
|
embeddedModels?.some(
|
|
(m) =>
|
|
m.name === modelName &&
|
|
(modelDirectory === undefined ||
|
|
m.directory === undefined ||
|
|
m.directory === modelDirectory)
|
|
)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
// widgets_values carries only the name, so directory cannot be
|
|
// checked here — fall back to filename matching.
|
|
const values = node.widgets_values
|
|
if (!values) continue
|
|
const valueArray = Array.isArray(values) ? values : Object.values(values)
|
|
for (const v of valueArray) {
|
|
if (typeof v === 'string' && v === modelName) return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
function isAncestorPathActiveInFlattened(
|
|
executionId: string,
|
|
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
|
): boolean {
|
|
for (const ancestorId of getParentExecutionIds(executionId)) {
|
|
const ancestor = nodeById.get(ancestorId)
|
|
if (!ancestor) continue
|
|
if (
|
|
ancestor.mode === LGraphEventMode.NEVER ||
|
|
ancestor.mode === LGraphEventMode.BYPASS
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function collectEmbeddedModelsWithSource(
|
|
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
|
graphData: MissingModelWorkflowData
|
|
): EmbeddedModelWithSource[] {
|
|
const result: EmbeddedModelWithSource[] = []
|
|
|
|
for (const node of allNodes) {
|
|
if (
|
|
node.mode === LGraphEventMode.NEVER ||
|
|
node.mode === LGraphEventMode.BYPASS
|
|
)
|
|
continue
|
|
|
|
const selected = getSelectedModelsMetadata(node)
|
|
if (!selected?.length) continue
|
|
|
|
for (const model of selected) {
|
|
result.push({
|
|
...model,
|
|
sourceNodeId: node.id,
|
|
sourceNodeType: node.type,
|
|
sourceWidgetName: findWidgetNameForModel(node, model.name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Workflow-level model entries have no originating node; sourceNodeId
|
|
// remains undefined and empty-string node type/widget are handled by
|
|
// groupCandidatesByName (no nodeId → no referencing node entry).
|
|
if (graphData.models?.length) {
|
|
for (const model of graphData.models) {
|
|
result.push({
|
|
...model,
|
|
sourceNodeType: '',
|
|
sourceWidgetName: ''
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function findWidgetNameForModel(
|
|
node: ReturnType<typeof flattenWorkflowNodes>[number],
|
|
modelName: string
|
|
): string {
|
|
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
|
|
for (const [key, val] of Object.entries(node.widgets_values)) {
|
|
if (val === modelName) return key
|
|
}
|
|
return ''
|
|
}
|
|
|
|
interface AssetVerifier {
|
|
updateModelsForNodeType: (nodeType: string) => Promise<void>
|
|
getAssets: (nodeType: string) => AssetItem[] | undefined
|
|
}
|
|
|
|
type AssetHashVerifier = (
|
|
assetHash: string,
|
|
signal?: AbortSignal
|
|
) => Promise<AssetHashStatus>
|
|
|
|
export async function verifyAssetSupportedCandidates(
|
|
candidates: MissingModelCandidate[],
|
|
signal?: AbortSignal,
|
|
assetsStore?: AssetVerifier,
|
|
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash
|
|
): Promise<void> {
|
|
if (signal?.aborted) return
|
|
|
|
const pendingCandidates = candidates.filter(
|
|
(c) => c.isAssetSupported && c.isMissing === undefined
|
|
)
|
|
if (pendingCandidates.length === 0) return
|
|
|
|
const pendingNodeTypes = new Set<string>()
|
|
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
|
|
|
|
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)
|
|
}
|
|
})
|
|
)
|
|
|
|
if (signal?.aborted) return
|
|
if (pendingNodeTypes.size === 0) return
|
|
|
|
const store =
|
|
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
|
|
|
const failedNodeTypes = new Set<string>()
|
|
await Promise.allSettled(
|
|
[...pendingNodeTypes].map(async (nodeType) => {
|
|
if (signal?.aborted) return
|
|
try {
|
|
await store.updateModelsForNodeType(nodeType)
|
|
} catch (err) {
|
|
failedNodeTypes.add(nodeType)
|
|
console.warn(
|
|
`[Missing Model Pipeline] Failed to load assets for ${nodeType}:`,
|
|
err
|
|
)
|
|
}
|
|
})
|
|
)
|
|
|
|
if (signal?.aborted) return
|
|
|
|
for (const c of candidates) {
|
|
if (!c.isAssetSupported || c.isMissing !== undefined) continue
|
|
if (failedNodeTypes.has(c.nodeType)) continue
|
|
|
|
const assets = store.getAssets(c.nodeType) ?? []
|
|
c.isMissing = !isAssetInstalled(c, assets)
|
|
}
|
|
}
|
|
|
|
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, '/')
|
|
}
|
|
|
|
function isAssetInstalled(
|
|
candidate: MissingModelCandidate,
|
|
assets: AssetItem[]
|
|
): boolean {
|
|
if (candidate.hash && candidate.hashType) {
|
|
const candidateHash = `${candidate.hashType}:${candidate.hash}`
|
|
if (assets.some((a) => a.asset_hash === candidateHash)) return true
|
|
}
|
|
|
|
const normalizedName = normalizePath(candidate.name)
|
|
return assets.some((a) => {
|
|
const f = normalizePath(getAssetFilename(a))
|
|
return f === normalizedName || f.endsWith('/' + normalizedName)
|
|
})
|
|
}
|
|
|
|
export function groupCandidatesByName(
|
|
candidates: MissingModelCandidate[]
|
|
): MissingModelViewModel[] {
|
|
const map = new Map<string, MissingModelViewModel>()
|
|
for (const c of candidates) {
|
|
const existing = map.get(c.name)
|
|
if (existing) {
|
|
if (c.nodeId) {
|
|
existing.referencingNodes.push({
|
|
nodeId: c.nodeId,
|
|
widgetName: c.widgetName
|
|
})
|
|
}
|
|
} else {
|
|
map.set(c.name, {
|
|
name: c.name,
|
|
representative: c,
|
|
referencingNodes: c.nodeId
|
|
? [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
|
: []
|
|
})
|
|
}
|
|
}
|
|
return Array.from(map.values())
|
|
}
|