mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-31 05:19:53 +00:00
The third PR for managing display of widgets on subgraph nodes. This is the one that actually makes the functionality usable and user visible. Adds - A right-side modal for configuring which widgets are promoted, accessed by right click or selection toolbar - This menu allows for re-arranging widget order by dragging and dropping. - Indicators inside the subgraph for which widgets have been promoted. - Context menu options for promoting or demoting widget inside of a subgraph. <img width="767" height="694" alt="image" src="https://github.com/user-attachments/assets/4f78645d-7b26-48ba-8c49-78f4807e89e8" /> <img width="784" height="435" alt="image" src="https://github.com/user-attachments/assets/7005c730-a732-481e-befb-57019a8a31a7" /> Known issues - Some preview widgets are not added to a node until a draw operation occurs. The code does not yet have a way of determining which nodes should have draw operations forced to facilitate initial widget creation. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5826-Add-UI-code-for-configuring-subgraphNode-widgets-27c6d73d36508146accbf395e5bcd36a) by [Unito](https://www.unito.io)
366 lines
11 KiB
TypeScript
366 lines
11 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 }> = {}
|
|
|
|
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 = {}
|
|
) {
|
|
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]
|
|
}
|
|
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 resetAllOutputsAndPreviews() {
|
|
app.nodeOutputs = {}
|
|
nodeOutputs.value = {}
|
|
revokeAllPreviews()
|
|
}
|
|
|
|
return {
|
|
// Getters
|
|
getNodeOutputs,
|
|
getNodeImageUrls,
|
|
getNodePreviews,
|
|
getPreviewParam,
|
|
|
|
// Setters
|
|
setNodeOutputs,
|
|
setNodeOutputsByExecutionId,
|
|
setNodePreviewsByExecutionId,
|
|
setNodePreviewsByNodeId,
|
|
|
|
// Cleanup
|
|
revokePreviewsByExecutionId,
|
|
revokeAllPreviews,
|
|
revokeSubgraphPreviews,
|
|
removeNodeOutputs,
|
|
restoreOutputs,
|
|
resetAllOutputsAndPreviews,
|
|
|
|
// State
|
|
nodeOutputs,
|
|
nodePreviewImages
|
|
}
|
|
})
|