mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 14:16:00 +00:00
## Summary After deleting an asset, the Load Image node kept displaying the deleted thumbnail — both in the node body and in the picker dropdown (All / Imported / Generated tabs), even after a workflow reload. - Fixes FE-230 - Source: Slack https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776715727656809 ## Root Cause Three distinct paths kept the deleted asset visible: 1. **Node-body preview cache** — `useMediaAssetActions.deleteAssets` never cleared `node.imgs` / `node.videoContainer` / the `nodeOutputStore` Vue ref, so the canvas renderer kept its cached frame. 2. **Live-delete dropdown gap** — the picker reads from `outputMediaAssets.media` (the asset list) and from `missingMediaStore.missingMediaCandidates` (verified-missing names). On live delete, neither was updated for the deleted asset, so the dropdown filter had nothing to drop. 3. **Synthetic "selected" placeholder** — `useWidgetSelectItems.missingValueItem` rebuilt any orphaned `modelValue` as a fake item with a `/api/view?filename=...` preview URL. Browsers had cached that URL pre-delete, so the deleted thumbnail still rendered with a blue checkmark even after the filter dropped the real asset entry. A subtler issue compounded #2/#3: candidate names stored in `missingMediaStore` are raw widget values (e.g. `sub/foo.png [output]`), but the dropdown computed comparison keys differently per source (asset list uses bare `asset.name`, widget option list uses bare filename). Names with a subfolder prefix slipped through the filter. ## Fix - **`clearNodePreviewCacheForFilenames`** (existing helper, refactored): exports `findNodesReferencingFilenames` + `extractFilenameFromWidgetValue`. Uses `nodeOutputStore.removeNodeOutputs` so the **reactive** Pinia ref updates, not just the legacy `app.nodeOutputs` mirror. Also clears `node.videoContainer` for Load Video. - **`markDeletedAssetsAsMissingMedia`** (new): on successful deletion, surfaces the affected widgets through `missingMediaStore` immediately so the dropdown filter has something to drop without waiting for verification. - **`useMissingMediaPreviewSync`** (new): watches `missingMediaStore` and clears `node.imgs` / `node.videoContainer` / Vue preview source for nodes referencing confirmed-missing media on workflow load — covers the post-reload case. - **`useWidgetSelectItems`**: normalizes both sides of the missing-media filter via `extractFilenameFromWidgetValue` (strips `[input|output|temp]` annotation + subfolder prefix), and suppresses `missingValueItem` when the value is in the missing-media store so the cached-thumbnail "selected" placeholder doesn't appear. ## Red-Green Verification | Commit | CI Status | Run | |--------|-----------|-----| | `test: FE-230 add failing test for Load Image preview cache clearing` | 🔴 Failure — test caught the bug | https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700188700 | | `fix: FE-230 clear Load Image preview cache when asset is deleted` | 🟢 Success | https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700265884 | ## Test Plan - [x] Unit coverage: 78 tests across 5 files (preview-cache helper, mark-deleted-as-missing, missing-media-preview-sync, widget-select-items missing-media filter incl. subfolder-prefix case, useMediaAssetActions integration) - [x] Live delete: Load Image node preview clears, dropdown drops the asset across All / Imported / Generated, no synthetic "selected" placeholder - [x] Post-reload: missing-media verification → `useMissingMediaPreviewSync` clears the preview, dropdown drops the asset - [x] Linear FE-230 auto-links via the Source line ## Scope note In-session and session-restore are both covered. If the backend/CDN continues serving the deleted `filename`/`asset_hash` after deletion, a cross-session reopen may still render stale bytes from cache — that's a backend/CDN concern tracked separately. ## demo ### before https://github.com/user-attachments/assets/e4d3a40e-0d46-43ad-985c-22ce7e0d3faf ### after https://github.com/user-attachments/assets/fcac9387-4c07-4be2-bcdd-d1a6192fe962
512 lines
15 KiB
TypeScript
512 lines
15 KiB
TypeScript
import { useTimeoutFn } from '@vueuse/core'
|
|
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
|
|
import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
|
import type {
|
|
ExecutedWsMessage,
|
|
ResultItem,
|
|
ResultItemType
|
|
} from '@/schemas/apiSchema'
|
|
import { api } from '@/scripts/api'
|
|
import { app } from '@/scripts/app'
|
|
import { clone } from '@/scripts/utils'
|
|
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
|
import { parseFilePath } from '@/utils/formatUtil'
|
|
import {
|
|
isAnimatedOutput,
|
|
isVideoNode,
|
|
resolveNode
|
|
} from '@/utils/litegraphUtil'
|
|
import {
|
|
releaseSharedObjectUrl,
|
|
retainSharedObjectUrl
|
|
} from '@/utils/objectUrlUtil'
|
|
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
|
|
|
const PREVIEW_REVOKE_DELAY_MS = 400
|
|
|
|
const createOutputs = (
|
|
filenames: string[],
|
|
type: ResultItemType,
|
|
isAnimated: boolean
|
|
): ExecutedWsMessage['output'] => {
|
|
return {
|
|
images: filenames.map((image) => ({ type, ...parseFilePath(image) })),
|
|
animated: filenames.map(
|
|
(image) =>
|
|
isAnimated && (image.endsWith('.webp') || image.endsWith('.png'))
|
|
)
|
|
}
|
|
}
|
|
|
|
interface SetOutputOptions {
|
|
merge?: boolean
|
|
}
|
|
|
|
export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
|
const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore()
|
|
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
|
|
const latestPreview = ref<string[]>([])
|
|
|
|
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
|
|
scheduledRevoke[locator]?.stop()
|
|
|
|
const { stop } = useTimeoutFn(() => {
|
|
delete scheduledRevoke[locator]
|
|
cb()
|
|
}, PREVIEW_REVOKE_DELAY_MS)
|
|
|
|
scheduledRevoke[locator] = { stop }
|
|
}
|
|
|
|
const nodeOutputs = ref<Record<string, ExecutedWsMessage['output']>>({})
|
|
|
|
// Reactive state for node preview images - mirrors app.nodePreviewImages
|
|
const nodePreviewImages = ref<Record<string, string[]>>(
|
|
app.nodePreviewImages || {}
|
|
)
|
|
|
|
function getNodeOutputs(
|
|
node: LGraphNode
|
|
): ExecutedWsMessage['output'] | undefined {
|
|
return app.nodeOutputs[nodeToNodeLocatorId(node)]
|
|
}
|
|
|
|
function getNodePreviews(node: LGraphNode): string[] | undefined {
|
|
return app.nodePreviewImages[nodeToNodeLocatorId(node)]
|
|
}
|
|
|
|
/**
|
|
* Check if a node's outputs includes images that should/can be loaded normally
|
|
* by PIL.
|
|
*/
|
|
const isImageOutputs = (
|
|
node: LGraphNode,
|
|
outputs: ExecutedWsMessage['output']
|
|
): boolean => {
|
|
// If animated webp/png or video outputs, return false
|
|
if (isAnimatedOutput(outputs) || isVideoNode(node)) return false
|
|
|
|
// If no images, return false
|
|
if (!outputs?.images?.length) return false
|
|
|
|
// If svg images, return false
|
|
if (outputs.images.some((image) => image.filename?.endsWith('svg')))
|
|
return false
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Get the preview param for the node's outputs.
|
|
*
|
|
* If the output is an image, use the user's preferred format (from settings).
|
|
* For non-image outputs, return an empty string, as including the preview param
|
|
* will force the server to load the output file as an image.
|
|
*/
|
|
function getPreviewParam(
|
|
node: LGraphNode,
|
|
outputs: ExecutedWsMessage['output']
|
|
): string {
|
|
return isImageOutputs(node, outputs) ? app.getPreviewFormatParam() : ''
|
|
}
|
|
|
|
function getNodeImageUrls(node: LGraphNode): string[] | undefined {
|
|
const previews = getNodePreviews(node)
|
|
if (previews?.length) return previews
|
|
|
|
const outputs = getNodeOutputs(node)
|
|
if (!outputs?.images?.length) return
|
|
|
|
const rand = app.getRandParam()
|
|
const previewParam = getPreviewParam(node, outputs)
|
|
|
|
return outputs.images.map((image) => {
|
|
const params = new URLSearchParams(image)
|
|
return api.apiURL(`/view?${params}${previewParam}${rand}`)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Check if an output contains input-type preview images (from upload widgets).
|
|
* These are synthetic previews set by LoadImage/LoadVideo widgets, not
|
|
* execution results from the backend.
|
|
*/
|
|
function isInputPreviewOutput(
|
|
output: ExecutedWsMessage['output'] | ResultItem | undefined
|
|
): boolean {
|
|
const images = (output as ExecutedWsMessage['output'] | undefined)?.images
|
|
return (
|
|
Array.isArray(images) &&
|
|
images.length > 0 &&
|
|
images.every((i) => i?.type === 'input')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Internal function to set outputs by NodeLocatorId.
|
|
* Handles the merge logic when needed.
|
|
*/
|
|
function setOutputsByLocatorId(
|
|
nodeLocatorId: NodeLocatorId,
|
|
outputs: ExecutedWsMessage['output'] | ResultItem,
|
|
options: SetOutputOptions = {}
|
|
) {
|
|
// Skip if outputs is null/undefined - preserve existing output
|
|
// This can happen when backend returns null for cached/deduplicated nodes
|
|
// (e.g., two LoadImage nodes selecting the same image)
|
|
if (outputs == null) return
|
|
|
|
// Preserve input preview images (from upload widgets) when execution
|
|
// sends outputs with no images. Without this guard, execution results
|
|
// overwrite the upload widget's preview, causing LoadImage/LoadVideo
|
|
// nodes to lose their preview after execution + tab switch.
|
|
// Note: intentional preview clears go through setNodeOutputs (widget
|
|
// path), not setNodeOutputsByExecutionId, so this guard does not
|
|
// interfere with user-initiated clears.
|
|
const incomingImages = (outputs as ExecutedWsMessage['output']).images
|
|
const hasIncomingImages =
|
|
Array.isArray(incomingImages) && incomingImages.length > 0
|
|
if (
|
|
!hasIncomingImages &&
|
|
isInputPreviewOutput(app.nodeOutputs[nodeLocatorId])
|
|
) {
|
|
outputs = {
|
|
...outputs,
|
|
images: app.nodeOutputs[nodeLocatorId].images
|
|
}
|
|
}
|
|
|
|
if (options.merge) {
|
|
const existingOutput = app.nodeOutputs[nodeLocatorId]
|
|
if (existingOutput && outputs) {
|
|
for (const k in outputs) {
|
|
const existingValue = existingOutput[k]
|
|
const newValue = (outputs as Record<NodeLocatorId, unknown>)[k]
|
|
|
|
if (Array.isArray(existingValue) && Array.isArray(newValue)) {
|
|
existingOutput[k] = existingValue.concat(newValue)
|
|
} else {
|
|
existingOutput[k] = newValue
|
|
}
|
|
}
|
|
nodeOutputs.value[nodeLocatorId] = { ...existingOutput }
|
|
return
|
|
}
|
|
}
|
|
|
|
app.nodeOutputs[nodeLocatorId] = outputs
|
|
nodeOutputs.value[nodeLocatorId] = outputs
|
|
}
|
|
|
|
function setNodeOutputs(
|
|
node: LGraphNode,
|
|
filenames: string | string[] | ResultItem,
|
|
{
|
|
folder = 'input',
|
|
isAnimated = false
|
|
}: { folder?: ResultItemType; isAnimated?: boolean } = {}
|
|
) {
|
|
if (!filenames || !node) return
|
|
|
|
const locatorId = nodeToNodeLocatorId(node)
|
|
if (!locatorId) return
|
|
if (typeof filenames === 'string') {
|
|
setOutputsByLocatorId(
|
|
locatorId,
|
|
createOutputs([filenames], folder, isAnimated)
|
|
)
|
|
} else if (!Array.isArray(filenames)) {
|
|
setOutputsByLocatorId(locatorId, filenames)
|
|
} else {
|
|
const resultItems = createOutputs(filenames, folder, isAnimated)
|
|
if (!resultItems?.images?.length) return
|
|
setOutputsByLocatorId(locatorId, resultItems)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set node outputs by execution ID (hierarchical ID from backend).
|
|
* Converts the execution ID to a NodeLocatorId before storing.
|
|
*
|
|
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
|
|
* @param outputs - The outputs to store
|
|
* @param options - Options for setting outputs
|
|
* @param options.merge - If true, merge with existing outputs (arrays are concatenated)
|
|
*/
|
|
function setNodeOutputsByExecutionId(
|
|
executionId: string,
|
|
outputs: ExecutedWsMessage['output'] | ResultItem,
|
|
options: SetOutputOptions = {}
|
|
) {
|
|
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
|
if (!nodeLocatorId) return
|
|
|
|
setOutputsByLocatorId(nodeLocatorId, outputs, options)
|
|
}
|
|
|
|
/**
|
|
* Set node preview images by execution ID (hierarchical ID from backend).
|
|
* Converts the execution ID to a NodeLocatorId before storing.
|
|
*
|
|
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
|
|
* @param previewImages - Array of preview image URLs to store
|
|
*/
|
|
function setNodePreviewsByExecutionId(
|
|
executionId: string,
|
|
previewImages: string[]
|
|
) {
|
|
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
|
if (!nodeLocatorId) return
|
|
setNodePreviewsByLocatorId(nodeLocatorId, previewImages)
|
|
latestPreview.value = previewImages
|
|
}
|
|
|
|
/**
|
|
* Set node preview images by NodeLocatorId directly.
|
|
*/
|
|
function setNodePreviewsByLocatorId(
|
|
nodeLocatorId: NodeLocatorId,
|
|
previewImages: string[]
|
|
) {
|
|
const existingPreviews = app.nodePreviewImages[nodeLocatorId]
|
|
if (scheduledRevoke[nodeLocatorId]) {
|
|
scheduledRevoke[nodeLocatorId].stop()
|
|
delete scheduledRevoke[nodeLocatorId]
|
|
}
|
|
if (existingPreviews?.[Symbol.iterator]) {
|
|
for (const url of existingPreviews) {
|
|
releaseSharedObjectUrl(url)
|
|
}
|
|
}
|
|
for (const url of previewImages) {
|
|
retainSharedObjectUrl(url)
|
|
}
|
|
app.nodePreviewImages[nodeLocatorId] = previewImages
|
|
nodePreviewImages.value[nodeLocatorId] = previewImages
|
|
}
|
|
|
|
/**
|
|
* Set node preview images by node ID.
|
|
* Uses the current graph context to create the appropriate NodeLocatorId.
|
|
*
|
|
* @param nodeId - The node ID
|
|
* @param previewImages - Array of preview image URLs to store
|
|
*/
|
|
function setNodePreviewsByNodeId(
|
|
nodeId: string | number,
|
|
previewImages: string[]
|
|
) {
|
|
setNodePreviewsByLocatorId(nodeIdToNodeLocatorId(nodeId), previewImages)
|
|
}
|
|
|
|
/**
|
|
* Revoke preview images by execution ID.
|
|
* Frees memory allocated to image preview blobs by revoking the URLs.
|
|
*
|
|
* @param executionId - The execution ID
|
|
*/
|
|
function revokePreviewsByExecutionId(executionId: string) {
|
|
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
|
if (!nodeLocatorId) return
|
|
scheduleRevoke(nodeLocatorId, () =>
|
|
revokePreviewsByLocatorId(nodeLocatorId)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Revoke preview images by node locator ID.
|
|
* Frees memory allocated to image preview blobs by revoking the URLs.
|
|
*
|
|
* @param nodeLocatorId - The node locator ID
|
|
*/
|
|
function revokePreviewsByLocatorId(nodeLocatorId: NodeLocatorId) {
|
|
const previews = app.nodePreviewImages[nodeLocatorId]
|
|
if (!previews?.[Symbol.iterator]) return
|
|
|
|
for (const url of previews) {
|
|
releaseSharedObjectUrl(url)
|
|
}
|
|
|
|
delete app.nodePreviewImages[nodeLocatorId]
|
|
delete nodePreviewImages.value[nodeLocatorId]
|
|
}
|
|
|
|
/**
|
|
* Revoke all preview images.
|
|
* Frees memory allocated to all image preview blobs.
|
|
*/
|
|
function revokeAllPreviews() {
|
|
for (const nodeLocatorId of Object.keys(app.nodePreviewImages)) {
|
|
const previews = app.nodePreviewImages[nodeLocatorId]
|
|
if (!previews?.[Symbol.iterator]) continue
|
|
|
|
for (const url of previews) {
|
|
releaseSharedObjectUrl(url)
|
|
}
|
|
}
|
|
app.nodePreviewImages = {}
|
|
nodePreviewImages.value = {}
|
|
}
|
|
|
|
/**
|
|
* Revoke all preview of a subgraph node and the graph it contains.
|
|
* Does not recurse to contents of nested subgraphs.
|
|
*/
|
|
function revokeSubgraphPreviews(subgraphNode: SubgraphNode) {
|
|
const { graph } = subgraphNode
|
|
if (!graph) return
|
|
|
|
const graphId = graph.isRootGraph ? '' : graph.id + ':'
|
|
revokePreviewsByLocatorId(graphId + subgraphNode.id)
|
|
for (const node of subgraphNode.subgraph.nodes) {
|
|
revokePreviewsByLocatorId(subgraphNode.subgraph.id + node.id)
|
|
}
|
|
}
|
|
|
|
function removeOutputsByLocatorId(nodeLocatorId: NodeLocatorId) {
|
|
const hadOutputs = !!app.nodeOutputs[nodeLocatorId]
|
|
delete app.nodeOutputs[nodeLocatorId]
|
|
delete nodeOutputs.value[nodeLocatorId]
|
|
|
|
if (app.nodePreviewImages[nodeLocatorId]) {
|
|
const previews = app.nodePreviewImages[nodeLocatorId]
|
|
if (previews?.[Symbol.iterator]) {
|
|
for (const url of previews) {
|
|
releaseSharedObjectUrl(url)
|
|
}
|
|
}
|
|
delete app.nodePreviewImages[nodeLocatorId]
|
|
delete nodePreviewImages.value[nodeLocatorId]
|
|
}
|
|
|
|
return hadOutputs
|
|
}
|
|
|
|
/**
|
|
* Remove node outputs for a specific node
|
|
* Clears both outputs and preview images
|
|
*/
|
|
function removeNodeOutputs(nodeId: number | string) {
|
|
const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
|
|
if (!nodeLocatorId) return false
|
|
return removeOutputsByLocatorId(nodeLocatorId)
|
|
}
|
|
|
|
// Resolves the locator from the node's own graph, so interior subgraph nodes
|
|
// are addressed correctly even when the user has a different graph active.
|
|
function removeNodeOutputsForNode(node: LGraphNode) {
|
|
return removeOutputsByLocatorId(nodeToNodeLocatorId(node))
|
|
}
|
|
|
|
function snapshotOutputs(): Record<string, ExecutedWsMessage['output']> {
|
|
return clone(app.nodeOutputs)
|
|
}
|
|
|
|
function restoreOutputs(
|
|
outputs: Record<string, ExecutedWsMessage['output']>
|
|
) {
|
|
app.nodeOutputs = outputs
|
|
nodeOutputs.value = { ...outputs }
|
|
}
|
|
|
|
function updateNodeImages(node: LGraphNode) {
|
|
if (!node.images?.length) return
|
|
|
|
const nodeLocatorId = nodeIdToNodeLocatorId(node.id)
|
|
|
|
if (nodeLocatorId) {
|
|
const existingOutputs = app.nodeOutputs[nodeLocatorId]
|
|
|
|
if (existingOutputs) {
|
|
const updatedOutputs = {
|
|
...existingOutputs,
|
|
images: node.images
|
|
}
|
|
|
|
app.nodeOutputs[nodeLocatorId] = updatedOutputs
|
|
nodeOutputs.value[nodeLocatorId] = updatedOutputs
|
|
}
|
|
}
|
|
}
|
|
|
|
function refreshNodeOutputs(node: LGraphNode) {
|
|
const locatorId = nodeToNodeLocatorId(node)
|
|
if (!locatorId) return
|
|
|
|
const outputs = app.nodeOutputs[locatorId]
|
|
if (!outputs) return
|
|
|
|
nodeOutputs.value[locatorId] = { ...outputs }
|
|
}
|
|
|
|
function resetAllOutputsAndPreviews() {
|
|
app.nodeOutputs = {}
|
|
nodeOutputs.value = {}
|
|
revokeAllPreviews()
|
|
}
|
|
|
|
/**
|
|
* Sync legacy node.imgs property for backwards compatibility.
|
|
*
|
|
* In Vue Nodes mode, legacy systems (Copy Image, Open Image, Save Image,
|
|
* Open in Mask Editor) rely on `node.imgs` containing HTMLImageElement
|
|
* references. Since Vue handles image rendering, we need to sync the
|
|
* already-loaded element from the Vue component to the node.
|
|
*
|
|
* @param nodeId - The node ID
|
|
* @param element - The loaded HTMLImageElement from the Vue component
|
|
* @param activeIndex - The current image index (for multi-image outputs)
|
|
*/
|
|
function syncLegacyNodeImgs(
|
|
nodeId: string | number,
|
|
element: HTMLImageElement,
|
|
activeIndex: number = 0
|
|
) {
|
|
if (!LiteGraph.vueNodesMode) return
|
|
|
|
const node = resolveNode(Number(nodeId))
|
|
if (!node) return
|
|
|
|
node.imgs = [element]
|
|
node.imageIndex = activeIndex
|
|
}
|
|
|
|
return {
|
|
// Getters
|
|
getNodeOutputs,
|
|
getNodeImageUrls,
|
|
getNodePreviews,
|
|
getPreviewParam,
|
|
|
|
// Setters
|
|
setNodeOutputs,
|
|
setNodeOutputsByExecutionId,
|
|
setNodePreviewsByExecutionId,
|
|
setNodePreviewsByLocatorId,
|
|
setNodePreviewsByNodeId,
|
|
updateNodeImages,
|
|
refreshNodeOutputs,
|
|
syncLegacyNodeImgs,
|
|
|
|
// Cleanup
|
|
revokePreviewsByExecutionId,
|
|
revokePreviewsByLocatorId,
|
|
revokeAllPreviews,
|
|
revokeSubgraphPreviews,
|
|
removeNodeOutputs,
|
|
removeNodeOutputsForNode,
|
|
snapshotOutputs,
|
|
restoreOutputs,
|
|
resetAllOutputsAndPreviews,
|
|
|
|
// State
|
|
nodeOutputs,
|
|
nodePreviewImages,
|
|
latestPreview
|
|
}
|
|
})
|