mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-08 09:00:05 +00:00
* add image outputs on Vue nodes * add unit tests and update cursor pointer * use testing pinia * properly mock i18n in component test * get node via current graph * use subgraph ID from node creation * add better error handling for downloadFile util * refactor: simplify image preview component architecture - Replace awkward composable pattern with standard Vue component state - Fix reactivity issues where images didn't update on new outputs - Add proper subgraph-aware node resolution using NodeLocatorId - Enhance accessibility with keyboard navigation and ARIA labels - Add comprehensive error handling and loading states - Include PrimeVue Skeleton for better loading UX - Remove unused composable and test files The image preview now properly updates when new outputs are generated and follows standard Vue reactivity patterns. * resolve merge conflict with main - Keep both subgraphId field and hasErrors field from main - No conflicts in other files (LGraphNode.vue and main.json merged cleanly) * Fix LGraphNode test by adding proper Pinia testing setup Added createTestingPinia and i18n configuration following the pattern from working ImagePreview tests. Resolves test failures due to missing Pinia store dependencies. All 6 tests now pass successfully.
324 lines
9.2 KiB
TypeScript
324 lines
9.2 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
|
|
import {
|
|
LGraphNode,
|
|
Subgraph,
|
|
SubgraphNode
|
|
} from '@/lib/litegraph/src/litegraph'
|
|
import {
|
|
ExecutedWsMessage,
|
|
ResultItem,
|
|
ResultItemType
|
|
} from '@/schemas/apiSchema'
|
|
import { api } from '@/scripts/api'
|
|
import { app } from '@/scripts/app'
|
|
import { useExecutionStore } from '@/stores/executionStore'
|
|
import { useWorkflowStore } from '@/stores/workflowStore'
|
|
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
|
import { parseFilePath } from '@/utils/formatUtil'
|
|
import { isVideoNode } from '@/utils/litegraphUtil'
|
|
|
|
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 } = useWorkflowStore()
|
|
const { executionIdToNodeLocatorId } = useExecutionStore()
|
|
|
|
const nodeOutputs = ref<Record<string, ExecutedWsMessage['output']>>({})
|
|
|
|
function getNodeOutputs(
|
|
node: LGraphNode
|
|
): ExecutedWsMessage['output'] | undefined {
|
|
return app.nodeOutputs[nodeIdToNodeLocatorId(node.id)]
|
|
}
|
|
|
|
function getNodePreviews(node: LGraphNode): string[] | undefined {
|
|
return app.nodePreviewImages[nodeIdToNodeLocatorId(node.id)]
|
|
}
|
|
|
|
/**
|
|
* 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 = {}
|
|
) {
|
|
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 =
|
|
node.graph instanceof Subgraph
|
|
? nodeIdToNodeLocatorId(node.id, node.graph ?? undefined)
|
|
: `${node.id}`
|
|
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
|
|
|
|
app.nodePreviewImages[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)
|
|
app.nodePreviewImages[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
|
|
|
|
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]
|
|
}
|
|
|
|
/**
|
|
* 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 = {}
|
|
}
|
|
|
|
/**
|
|
* 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]
|
|
}
|
|
|
|
return hadOutputs
|
|
}
|
|
|
|
return {
|
|
// Getters
|
|
getNodeOutputs,
|
|
getNodeImageUrls,
|
|
getNodePreviews,
|
|
getPreviewParam,
|
|
|
|
// Setters
|
|
setNodeOutputs,
|
|
setNodeOutputsByExecutionId,
|
|
setNodePreviewsByExecutionId,
|
|
setNodePreviewsByNodeId,
|
|
|
|
// Cleanup
|
|
revokePreviewsByExecutionId,
|
|
revokeAllPreviews,
|
|
revokeSubgraphPreviews,
|
|
removeNodeOutputs,
|
|
|
|
// State
|
|
nodeOutputs
|
|
}
|
|
})
|