mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 14:54:12 +00:00
## Summary
When two LoadImage nodes select the same image, the backend's cache
mechanism returns null output for the second node (same cache
signature). This was overwriting the existing image preview data.
Now skip setting outputs when null/undefined to preserve the preview.
Root cause chain
07f2462eae/execution.py (L421):
1. When two LoadImage nodes select the same image, they have identical
cache signatures (based on IS_CHANGED SHA256 hash + input parameters)
2. First node executes: Actually runs load_image(), caches the result.
But LoadImage's ui field is None (it only produces tensors, no UI
output)
3. Second node executes: Cache hit, directly returns
cached_ui.get("output", None) = null
4. Frontend receives null and overwrites the existing image preview
## Screenshots (if applicable)
Before
https://github.com/user-attachments/assets/7bd814f6-bf23-42cc-9fc3-fd9fec68b4f6
After
https://github.com/user-attachments/assets/b9cc6160-ea70-424e-8a3d-5dc9f244d0d0
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8050-fix-preserve-image-preview-when-backend-returns-null-output-2e86d73d36508156ab32cb12a7f6b307)
by [Unito](https://www.unito.io)
395 lines
12 KiB
TypeScript
395 lines
12 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 { 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 { useExecutionStore } from '@/stores/executionStore'
|
|
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
|
import { parseFilePath } from '@/utils/formatUtil'
|
|
import { isVideoNode } from '@/utils/litegraphUtil'
|
|
|
|
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 { executionIdToNodeLocatorId } = useExecutionStore()
|
|
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 (node.animatedImages || 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 imgUrlPart = new URLSearchParams(image)
|
|
return api.apiURL(`/view?${imgUrlPart}${previewParam}${rand}`)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
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, any>)[k]
|
|
|
|
if (Array.isArray(existingValue) && Array.isArray(newValue)) {
|
|
existingOutput[k] = existingValue.concat(newValue)
|
|
} else {
|
|
existingOutput[k] = newValue
|
|
}
|
|
}
|
|
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(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(executionId)
|
|
if (!nodeLocatorId) return
|
|
if (scheduledRevoke[nodeLocatorId]) {
|
|
scheduledRevoke[nodeLocatorId].stop()
|
|
delete scheduledRevoke[nodeLocatorId]
|
|
}
|
|
latestPreview.value = previewImages
|
|
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[]
|
|
) {
|
|
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
|
|
if (scheduledRevoke[nodeLocatorId]) {
|
|
scheduledRevoke[nodeLocatorId].stop()
|
|
delete scheduledRevoke[nodeLocatorId]
|
|
}
|
|
app.nodePreviewImages[nodeLocatorId] = previewImages
|
|
nodePreviewImages.value[nodeLocatorId] = 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(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) {
|
|
URL.revokeObjectURL(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) {
|
|
URL.revokeObjectURL(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 graphId = subgraphNode.graph.isRootGraph
|
|
? ''
|
|
: subgraphNode.graph.id + ':'
|
|
revokePreviewsByLocatorId(graphId + subgraphNode.id)
|
|
for (const node of subgraphNode.subgraph.nodes) {
|
|
revokePreviewsByLocatorId(subgraphNode.subgraph.id + node.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
// Clear from app.nodeOutputs
|
|
const hadOutputs = !!app.nodeOutputs[nodeLocatorId]
|
|
delete app.nodeOutputs[nodeLocatorId]
|
|
|
|
// Clear from reactive state
|
|
delete nodeOutputs.value[nodeLocatorId]
|
|
|
|
// Clear preview images
|
|
if (app.nodePreviewImages[nodeLocatorId]) {
|
|
delete app.nodePreviewImages[nodeLocatorId]
|
|
delete nodePreviewImages.value[nodeLocatorId]
|
|
}
|
|
|
|
return hadOutputs
|
|
}
|
|
|
|
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 resetAllOutputsAndPreviews() {
|
|
app.nodeOutputs = {}
|
|
nodeOutputs.value = {}
|
|
revokeAllPreviews()
|
|
}
|
|
|
|
return {
|
|
// Getters
|
|
getNodeOutputs,
|
|
getNodeImageUrls,
|
|
getNodePreviews,
|
|
getPreviewParam,
|
|
|
|
// Setters
|
|
setNodeOutputs,
|
|
setNodeOutputsByExecutionId,
|
|
setNodePreviewsByExecutionId,
|
|
setNodePreviewsByNodeId,
|
|
updateNodeImages,
|
|
|
|
// Cleanup
|
|
revokePreviewsByExecutionId,
|
|
revokeAllPreviews,
|
|
revokeSubgraphPreviews,
|
|
removeNodeOutputs,
|
|
restoreOutputs,
|
|
resetAllOutputsAndPreviews,
|
|
|
|
// State
|
|
nodeOutputs,
|
|
nodePreviewImages,
|
|
latestPreview
|
|
}
|
|
})
|