mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-19 02:06:38 +00:00
## Summary Simplifies the Missing Models error card as the fifth slice of the catalog-driven error-tab redesign. This PR is intentionally larger than the previous slices because Missing Models is the only remaining error type where the card UI, OSS download flow, Cloud import flow, and shared model-import dialog all have to move together to preserve the resolution path. The high-level goal is to make Missing Models behave like the other simplified error cards: show the exact missing item, show the affected nodes, keep locate actions predictable, and only expose actions that can actually resolve the problem. This follows the staged error-tab cleanup plan: 1. #12683 refined validation, runtime, and prompt error presentation. 2. #12705 simplified missing media error presentation. 3. #12735 simplified missing node pack error presentation. 4. #12768 simplified swap node error presentation. 5. This PR simplifies missing model presentation and the model-import handoff. ## Why This PR Is Larger Missing Models has more resolution paths than the previous error groups: - OSS can refresh model state, download individual models, and download all available models. - Cloud cannot download directly from the panel; it resolves supported rows through the model import dialog. - Some Cloud rows cannot be resolved through import at all because the node/widget cannot consume imported model assets. - Importing from Cloud needs to know the originating missing-model row so it can lock the expected model type and apply the imported model back to the affected widgets. - Already-imported files can still be unusable if they were imported under a different model type than the missing node expects. Because of those constraints, splitting the card layout from the dialog handoff would leave either a misleading Import button or an import dialog that does not know what it is resolving. This PR keeps that behavior in one reviewable unit. ## User-Facing Behavior ### Shared Missing Models Card - Replaces the older grouped presentation with compact model rows. - Shows each missing model as the primary row label. - Shows model metadata as a smaller sublabel instead of using large section headers. - Keeps locate-node controls visually consistent with the other simplified error cards. - Keeps rows expandable when multiple nodes reference the same missing model. - Shows the affected node rows under expanded models. - Allows single-reference rows to locate the affected node without rendering an extra duplicate child row. - Keeps unknown rows visible, including their affected nodes, instead of silently hiding them. - Removes the old library-select UI from the Missing Models card. ### OSS Behavior - Keeps the refresh action available from the Missing Models group header. - Keeps individual Download actions for downloadable models. - Moves file size out of the Download button label and into the row sublabel. - Keeps Download all when multiple downloadable models are available. - Places Download all at the bottom of the card rather than competing with the group header. - Leaves rows without a download URL as non-downloadable instead of rendering a broken action. ### Cloud Behavior - Shows Import only for missing models that can be resolved by importing a model asset of the required type. - Separates models that cannot be resolved through Cloud import into an Import Not Supported section. - Gives unsupported rows a direct explanation: nodes referencing those models do not support imported models, so users need to open the node and choose a supported built-in model or replace the node with a supported loader. - Treats unknown model type/directory as unsupported for Cloud import, because the import dialog cannot lock a valid model type and the node cannot safely consume the imported asset. - Keeps affected nodes visible in the unsupported section so users still have a path to locate and replace the node manually. ## Cloud Import Dialog Changes The shared model import dialog now accepts missing-model context when opened from the Missing Models card. When that context is present: - The dialog shows which missing model will be replaced. - The dialog lists the affected node/widget references that will be updated. - The model type selector is locked to the required model directory/type. - The Back/import-another path is disabled when it would break the targeted missing-model flow. - Import progress can be associated with the originating missing-model row. - After import completion, matching missing-model references are applied automatically where possible. - If the selected file is already imported under an incompatible model type, the dialog shows a targeted failure state explaining why this import cannot resolve the missing model. This keeps the generic import dialog reusable while adding only the context-specific behavior needed for Missing Models. ## Implementation Notes - `MissingModelCard.vue` owns the card-level grouping and OSS/Cloud section decisions. - `MissingModelRow.vue` owns per-model row rendering, expansion, locate actions, import/download actions, and row-level progress states. - `useMissingModelInteractions.ts` remains the interaction layer for locating nodes and applying resolved model selections. - `UploadModelDialog.vue`, `UploadModelConfirmation.vue`, `UploadModelFooter.vue`, `UploadModelProgress.vue`, and `useUploadModelWizard.ts` receive the missing-model context needed by the Cloud import handoff. - `MissingModelLibrarySelect.vue` is removed because the simplified card no longer exposes that inline selection path. - Locale and selector changes are limited to the new simplified row/section states and removed unused Missing Models strings. ## Tests Added / Updated - Unit coverage for Missing Models card grouping and row states. - Unit coverage for importable vs unsupported Cloud rows. - Unit coverage for model row expansion, locate actions, progress display, and action availability. - Unit coverage for upload confirmation/footer/progress behavior when a missing-model context is present. - Unit coverage for incompatible already-imported model handling. - E2E coverage for OSS Missing Models presentation. - E2E coverage for mode-aware Missing Models interactions. - Cloud E2E coverage for importable rows vs Import Not Supported rows. - Cloud E2E coverage for opening the import dialog with missing-model replacement context. ## Review Focus - Cloud import eligibility: unsupported or unknown model rows should not expose Import as if the row can be resolved automatically. - Missing-model context in the import dialog: the required model type should be locked, and the affected node/widget references should be clear. - OSS parity: OSS should keep refresh, individual Download, and Download all while visually matching the simplified Cloud card where possible. - Narrow side panel behavior: row labels may wrap, but link, primary action, and locate controls should not overlap. - Scope boundaries: this PR intentionally does not redesign Missing Node Pack / Swap Node / Missing Media again; visual parity issues shared across those cards can be handled in a follow-up unification pass if needed. ## Validation - `pnpm format` - `pnpm lint` - `pnpm typecheck` - Related unit tests: 8 files / 84 tests passed - `pnpm build` - OSS Missing Models E2E: `errorsTabMissingModels.spec.ts` passed, 8/8 - Mode-aware Missing Models E2E subset passed, 11/11, excluding unrelated local paste clipboard cases - `pnpm build:cloud` - Cloud Missing Models E2E: `errorsTabCloudMissingModels.spec.ts` passed, 3/3 - Final Claude review: no Blocker or Major findings ## Breaking / Dependencies - Breaking: none. - Dependencies: none. ## Screenshots OSS <img width="575" height="393" alt="스크린샷 2026-06-12 오전 12 25 27" src="https://github.com/user-attachments/assets/f5c44f95-711a-4d3d-99bd-f39ac2bb2012" /> <img width="659" height="351" alt="스크린샷 2026-06-12 오전 12 24 37" src="https://github.com/user-attachments/assets/4bb65a47-c1aa-408b-836b-a1998412f815" /> Cloud <img width="688" height="357" alt="스크린샷 2026-06-12 오전 12 23 59" src="https://github.com/user-attachments/assets/9330a7e7-9f22-420f-82b3-dde0fb2b3dd1" /> <img width="531" height="437" alt="스크린샷 2026-06-12 오전 12 21 13" src="https://github.com/user-attachments/assets/734bd911-f6f7-4872-8868-bb927ddeedd8" /> New import model flow https://github.com/user-attachments/assets/c094c670-62b9-47ce-bfe1-2d09f4f7359d
2244 lines
72 KiB
TypeScript
2244 lines
72 KiB
TypeScript
import { useEventListener, useResizeObserver } from '@vueuse/core'
|
|
import _ from 'es-toolkit/compat'
|
|
import type { ToastMessageOptions } from 'primevue/toast'
|
|
import { reactive, unref } from 'vue'
|
|
import { shallowRef } from 'vue'
|
|
|
|
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
|
import { syncLayoutStoreNodeBoundsFromGraph } from '@/renderer/core/layout/sync/syncLayoutStoreFromGraph'
|
|
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
|
|
|
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
|
import { st, t } from '@/i18n'
|
|
import { ChangeTracker } from '@/scripts/changeTracker'
|
|
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
|
import {
|
|
LGraph,
|
|
LGraphCanvas,
|
|
LGraphNode,
|
|
LiteGraph
|
|
} from '@/lib/litegraph/src/litegraph'
|
|
import { snapPoint } from '@/lib/litegraph/src/measure'
|
|
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
|
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
|
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useTelemetry } from '@/platform/telemetry'
|
|
import { installNodeAddedTelemetry } from '@/platform/telemetry/nodeAdded/installNodeAddedTelemetry'
|
|
import { groupMissingNodesByPack } from '@/platform/telemetry/utils/groupMissingNodesByPack'
|
|
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
|
|
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
|
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
|
import { useWorkflowValidation } from '@/platform/workflow/validation/composables/useWorkflowValidation'
|
|
import type {
|
|
ComfyApiWorkflow,
|
|
ComfyWorkflowJSON,
|
|
NodeId
|
|
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
import {
|
|
collectSubgraphDefinitions,
|
|
buildSubgraphExecutionPaths
|
|
} from '@/platform/workflow/core/utils/workflowFlattening'
|
|
import type { FlattenableWorkflowNode } from '@/platform/workflow/core/utils/workflowFlattening'
|
|
import type {
|
|
ExecutionErrorWsMessage,
|
|
NodeError,
|
|
NodeExecutionOutput,
|
|
ResultItem
|
|
} from '@/schemas/apiSchema'
|
|
import {
|
|
type ComfyNodeDef as ComfyNodeDefV1,
|
|
isComboInputSpecV1,
|
|
isComboInputSpecV2
|
|
} from '@/schemas/nodeDefSchema'
|
|
import {
|
|
type BaseDOMWidget,
|
|
ComponentWidgetImpl,
|
|
DOMWidgetImpl
|
|
} from '@/scripts/domWidget'
|
|
import { useDialogService } from '@/services/dialogService'
|
|
import { useExtensionService } from '@/services/extensionService'
|
|
import { useLitegraphService } from '@/services/litegraphService'
|
|
import { useSubgraphService } from '@/services/subgraphService'
|
|
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
|
import { useCommandStore } from '@/stores/commandStore'
|
|
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
|
import { useExecutionStore } from '@/stores/executionStore'
|
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
|
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
|
import { useExtensionStore } from '@/stores/extensionStore'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
|
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
|
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
|
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
|
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
|
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
|
|
|
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
|
import { useSubgraphStore } from '@/stores/subgraphStore'
|
|
import { useWidgetStore } from '@/stores/widgetStore'
|
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
|
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
|
import type { ExtensionManager } from '@/types/extensionTypes'
|
|
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
|
import { graphToPrompt } from '@/utils/executionUtil'
|
|
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
|
|
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
|
|
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
|
|
import {
|
|
refreshMissingModelPipeline,
|
|
runMissingModelPipeline
|
|
} from '@/platform/missingModel/missingModelPipeline'
|
|
import type { MissingModelPipelineResult } from '@/platform/missingModel/missingModelPipeline'
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
|
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
|
import {
|
|
scanAllMediaCandidates,
|
|
verifyMediaCandidates
|
|
} from '@/platform/missingMedia/missingMediaScan'
|
|
|
|
import { anyItemOverlapsRect } from '@/utils/mathUtil'
|
|
import {
|
|
collectAllNodes,
|
|
forEachNode,
|
|
getNodeByExecutionId,
|
|
isAncestorPathActive,
|
|
isMissingCandidateActive,
|
|
triggerCallbackOnAllNodes
|
|
} from '@/utils/graphTraversalUtil'
|
|
import {
|
|
executeWidgetsCallback,
|
|
createNode,
|
|
fixLinkInputSlots,
|
|
isImageNode,
|
|
isVideoNode
|
|
} from '@/utils/litegraphUtil'
|
|
import {
|
|
createSharedObjectUrl,
|
|
releaseSharedObjectUrl
|
|
} from '@/utils/objectUrlUtil'
|
|
import {
|
|
findLegacyRerouteNodes,
|
|
noNativeReroutes
|
|
} from '@/utils/migration/migrateReroute'
|
|
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
|
|
import { getWidgetState } from '@/world/widgetValueIO'
|
|
|
|
import { type ComfyApi, PromptExecutionError, api } from './api'
|
|
import { defaultGraph } from './defaultGraph'
|
|
import { importA1111 } from './pnginfo'
|
|
import { $el, ComfyUI } from './ui'
|
|
import { ComfyAppMenu } from './ui/menu/index'
|
|
import { clone } from './utils'
|
|
import { type ComfyWidgetConstructor } from './widgets'
|
|
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
|
import {
|
|
extractFilesFromDragEvent,
|
|
hasAudioType,
|
|
hasImageType,
|
|
hasVideoType,
|
|
isMediaFile
|
|
} from '@/utils/eventUtils'
|
|
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
|
import {
|
|
pasteAudioNode,
|
|
pasteAudioNodes,
|
|
pasteImageNode,
|
|
pasteImageNodes,
|
|
pasteVideoNode,
|
|
pasteVideoNodes
|
|
} from '@/composables/usePaste'
|
|
|
|
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
|
|
|
|
export function sanitizeNodeName(string: string) {
|
|
let entityMap = {
|
|
'&': '',
|
|
'<': '',
|
|
'>': '',
|
|
'"': '',
|
|
"'": '',
|
|
'`': '',
|
|
'=': ''
|
|
}
|
|
return String(string).replace(/[&<>"'`=]/g, function fromEntityMap(s) {
|
|
return entityMap[s as keyof typeof entityMap]
|
|
})
|
|
}
|
|
|
|
function syncPromotedComboHostOptions(rootGraph: LGraph): void {
|
|
forEachNode(rootGraph, (node) => {
|
|
for (const widget of node.widgets ?? []) {
|
|
if (!isPromotedWidgetView(widget) || widget.type !== 'combo') continue
|
|
|
|
const state = getWidgetState(widget.entityId)
|
|
if (!state) continue
|
|
|
|
state.options = { ...(widget.options ?? {}) }
|
|
}
|
|
})
|
|
}
|
|
|
|
type Clipspace = {
|
|
widgets?: Pick<IBaseWidget, 'type' | 'name' | 'value'>[] | null
|
|
imgs?: HTMLImageElement[] | null
|
|
original_imgs?: HTMLImageElement[] | null
|
|
images?: ResultItem[] | null
|
|
selectedIndex: number
|
|
img_paste_mode: string
|
|
paintedIndex: number
|
|
combinedIndex: number
|
|
}
|
|
|
|
export class ComfyApp {
|
|
/**
|
|
* List of entries to queue
|
|
*/
|
|
private queueItems: {
|
|
number: number
|
|
batchCount: number
|
|
requestId: number
|
|
queueNodeIds?: NodeExecutionId[]
|
|
}[] = []
|
|
private nextQueueRequestId = 1
|
|
/**
|
|
* If the queue is currently being processed
|
|
*/
|
|
private processingQueue: boolean = false
|
|
|
|
/**
|
|
* Content Clipboard
|
|
* @type {serialized node object}
|
|
*/
|
|
static clipspace: Clipspace | null = null
|
|
static clipspace_invalidate_handler: (() => void) | null = null
|
|
static open_maskeditor: (() => void) | null = null
|
|
static maskeditor_is_opended: (() => void) | null = null
|
|
static clipspace_return_node = null
|
|
|
|
vueAppReady: boolean
|
|
api: ComfyApi
|
|
ui: ComfyUI
|
|
extensionManager!: ExtensionManager
|
|
private _nodeOutputs!: Record<string, NodeExecutionOutput>
|
|
nodePreviewImages: Record<string, string[]>
|
|
|
|
private rootGraphInternal: LGraph | undefined
|
|
|
|
// TODO: Migrate internal usage to the
|
|
/** @deprecated Use {@link rootGraph} instead */
|
|
get graph() {
|
|
return this.rootGraphInternal!
|
|
}
|
|
|
|
get rootGraph(): LGraph {
|
|
if (!this.rootGraphInternal) {
|
|
console.error('ComfyApp graph accessed before initialization')
|
|
}
|
|
return this.rootGraphInternal!
|
|
}
|
|
|
|
/** Whether the root graph has been initialized. Safe to check without triggering error logs. */
|
|
get isGraphReady(): boolean {
|
|
return !!this.rootGraphInternal
|
|
}
|
|
|
|
canvas!: LGraphCanvas
|
|
dragOverNode: Pick<LGraphNode, 'onDragDrop' | 'id'> | null = null
|
|
readonly canvasElRef = shallowRef<HTMLCanvasElement>()
|
|
get canvasEl() {
|
|
// TODO: Fix possibly undefined reference
|
|
return unref(this.canvasElRef)!
|
|
}
|
|
|
|
private configuringGraphLevel: number = 0
|
|
get configuringGraph() {
|
|
return this.configuringGraphLevel > 0
|
|
}
|
|
ctx!: CanvasRenderingContext2D
|
|
bodyTop: HTMLElement
|
|
bodyLeft: HTMLElement
|
|
bodyRight: HTMLElement
|
|
bodyBottom: HTMLElement
|
|
canvasContainer: HTMLElement
|
|
menu: ComfyAppMenu
|
|
// Set by Comfy.Clipspace extension
|
|
openClipspace: () => void = () => {}
|
|
|
|
private positionConversion?: {
|
|
clientPosToCanvasPos: (pos: Vector2) => Vector2
|
|
canvasPosToClientPos: (pos: Vector2) => Vector2
|
|
}
|
|
|
|
/**
|
|
* The node errors from the previous execution.
|
|
* @deprecated Use app.extensionManager.lastNodeErrors instead
|
|
*/
|
|
get lastNodeErrors(): Record<NodeId, NodeError> | null {
|
|
return useExecutionErrorStore().lastNodeErrors
|
|
}
|
|
|
|
/**
|
|
* The error from the previous execution.
|
|
* @deprecated Use app.extensionManager.lastExecutionError instead
|
|
*/
|
|
get lastExecutionError(): ExecutionErrorWsMessage | null {
|
|
return useExecutionErrorStore().lastExecutionError
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use useExecutionStore().executingNodeId instead
|
|
* TODO: Update to support multiple executing nodes. This getter returns only the first executing node.
|
|
* Consider updating consumers to handle multiple nodes or use executingNodeIds array.
|
|
*/
|
|
get runningNodeId(): NodeId | null {
|
|
return useExecutionStore().executingNodeId
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use useWorkspaceStore().shiftDown instead
|
|
*/
|
|
get shiftDown(): boolean {
|
|
return useWorkspaceStore().shiftDown
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use useWidgetStore().widgets instead
|
|
*/
|
|
get widgets(): Record<string, ComfyWidgetConstructor> {
|
|
return Object.fromEntries(useWidgetStore().widgets.entries())
|
|
}
|
|
|
|
/**
|
|
* @deprecated storageLocation is always 'server' since
|
|
* https://github.com/comfyanonymous/ComfyUI/commit/53c8a99e6c00b5e20425100f6680cd9ea2652218
|
|
*/
|
|
get storageLocation() {
|
|
return 'server'
|
|
}
|
|
|
|
/**
|
|
* @deprecated storage migration is no longer needed.
|
|
*/
|
|
get isNewUserSession() {
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use useExtensionStore().extensions instead
|
|
*/
|
|
get extensions(): ComfyExtension[] {
|
|
return useExtensionStore().extensions
|
|
}
|
|
|
|
/**
|
|
* The progress on the current executing node, if the node reports any.
|
|
* @deprecated Use useExecutionStore().executingNodeProgress instead
|
|
*/
|
|
get progress() {
|
|
return useExecutionStore()._executingNodeProgress
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use {@link isImageNode} from @/utils/litegraphUtil instead
|
|
*/
|
|
static isImageNode(node: LGraphNode) {
|
|
return isImageNode(node)
|
|
}
|
|
|
|
/**
|
|
* Resets the canvas view to the default
|
|
* @deprecated Use {@link useLitegraphService().resetView} instead
|
|
*/
|
|
resetView() {
|
|
useLitegraphService().resetView()
|
|
}
|
|
|
|
constructor() {
|
|
this.vueAppReady = false
|
|
this.ui = new ComfyUI(this)
|
|
this.api = api
|
|
// Dummy placeholder elements before GraphCanvas is mounted.
|
|
this.bodyTop = $el('div.comfyui-body-top')
|
|
this.bodyLeft = $el('div.comfyui-body-left')
|
|
this.bodyRight = $el('div.comfyui-body-right')
|
|
this.bodyBottom = $el('div.comfyui-body-bottom')
|
|
this.canvasContainer = $el('div.graph-canvas-container')
|
|
|
|
this.menu = new ComfyAppMenu(this)
|
|
|
|
/**
|
|
* Stores the execution output data for each node
|
|
* @type {Record<string, any>}
|
|
*/
|
|
this.nodeOutputs = {}
|
|
|
|
/**
|
|
* Stores the preview image data for each node
|
|
* @type {Record<string, Image>}
|
|
*/
|
|
this.nodePreviewImages = {}
|
|
}
|
|
|
|
get nodeOutputs() {
|
|
return this._nodeOutputs
|
|
}
|
|
|
|
set nodeOutputs(value) {
|
|
this._nodeOutputs = value
|
|
if (this.vueAppReady)
|
|
useExtensionService().invokeExtensions('onNodeOutputsUpdated', value)
|
|
}
|
|
|
|
/**
|
|
* If the user has specified a preferred format to receive preview images in,
|
|
* this function will return that format as a url query param.
|
|
* If the node's outputs are not images, this param should not be used, as it will
|
|
* force the server to load the output file as an image.
|
|
*/
|
|
getPreviewFormatParam() {
|
|
let preview_format = useSettingStore().get('Comfy.PreviewFormat')
|
|
if (preview_format) return `&preview=${preview_format}`
|
|
else return ''
|
|
}
|
|
|
|
getRandParam() {
|
|
if (isCloud) return ''
|
|
return '&rand=' + Math.random()
|
|
}
|
|
|
|
static onClipspaceEditorSave() {
|
|
if (ComfyApp.clipspace_return_node) {
|
|
ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node)
|
|
}
|
|
}
|
|
|
|
static onClipspaceEditorClosed() {
|
|
ComfyApp.clipspace_return_node = null
|
|
}
|
|
|
|
static copyToClipspace(node: LGraphNode) {
|
|
var widgets = null
|
|
if (node.widgets) {
|
|
widgets = node.widgets.map(({ type, name, value }) => ({
|
|
type,
|
|
name,
|
|
value
|
|
}))
|
|
}
|
|
|
|
var imgs = undefined
|
|
var orig_imgs = undefined
|
|
if (node.imgs != undefined) {
|
|
imgs = []
|
|
orig_imgs = []
|
|
|
|
for (let i = 0; i < node.imgs.length; i++) {
|
|
imgs[i] = new Image()
|
|
imgs[i].src = node.imgs[i].src
|
|
orig_imgs[i] = imgs[i]
|
|
}
|
|
}
|
|
|
|
var selectedIndex = 0
|
|
if (node.imageIndex) {
|
|
selectedIndex = node.imageIndex
|
|
}
|
|
|
|
const paintedIndex = imgs ? imgs.length + 1 : 1
|
|
const combinedIndex = imgs ? imgs.length + 2 : 2
|
|
|
|
// for vueNodes mode
|
|
const images =
|
|
node.images ?? useNodeOutputStore().getNodeOutputs(node)?.images
|
|
|
|
ComfyApp.clipspace = {
|
|
widgets: widgets,
|
|
imgs: imgs,
|
|
original_imgs: orig_imgs,
|
|
images: images,
|
|
selectedIndex: selectedIndex,
|
|
img_paste_mode: 'selected', // reset to default im_paste_mode state on copy action
|
|
paintedIndex: paintedIndex,
|
|
combinedIndex: combinedIndex
|
|
}
|
|
|
|
ComfyApp.clipspace_return_node = null
|
|
|
|
if (ComfyApp.clipspace_invalidate_handler) {
|
|
ComfyApp.clipspace_invalidate_handler()
|
|
}
|
|
}
|
|
|
|
static pasteFromClipspace(node: LGraphNode) {
|
|
if (ComfyApp.clipspace) {
|
|
// image paste
|
|
let combinedImgSrc: string | undefined
|
|
if (
|
|
ComfyApp.clipspace.combinedIndex !== undefined &&
|
|
ComfyApp.clipspace.imgs &&
|
|
ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length
|
|
) {
|
|
combinedImgSrc =
|
|
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
|
|
}
|
|
if (ComfyApp.clipspace.imgs && node.imgs) {
|
|
// Update node.images even if it's initially undefined (vueNodes mode)
|
|
if (ComfyApp.clipspace.images) {
|
|
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
|
|
node.images = [
|
|
ComfyApp.clipspace.images[ComfyApp.clipspace['selectedIndex']]
|
|
]
|
|
} else {
|
|
node.images = ComfyApp.clipspace.images
|
|
}
|
|
|
|
if (app.nodeOutputs[node.id + ''])
|
|
app.nodeOutputs[node.id + ''].images = node.images
|
|
}
|
|
|
|
if (ComfyApp.clipspace.imgs) {
|
|
// deep-copy to cut link with clipspace
|
|
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
|
|
const img = new Image()
|
|
img.src =
|
|
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src
|
|
node.imgs = [img]
|
|
node.imageIndex = 0
|
|
} else {
|
|
const imgs = []
|
|
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
|
|
imgs[i] = new Image()
|
|
imgs[i].src = ComfyApp.clipspace.imgs[i].src
|
|
node.imgs = imgs
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Paste the RGB canvas if paintedindex exists
|
|
if (
|
|
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.paintedIndex] &&
|
|
node.imgs
|
|
) {
|
|
const paintedImg = new Image()
|
|
paintedImg.src =
|
|
ComfyApp.clipspace.imgs[ComfyApp.clipspace.paintedIndex].src
|
|
node.imgs.push(paintedImg) // Add the RGB canvas to the node's images
|
|
}
|
|
|
|
// Store only combined image inside the node if it exists
|
|
if (
|
|
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex] &&
|
|
node.imgs &&
|
|
combinedImgSrc
|
|
) {
|
|
const combinedImg = new Image()
|
|
combinedImg.src = combinedImgSrc
|
|
node.imgs = [combinedImg]
|
|
}
|
|
|
|
if (node.widgets) {
|
|
if (ComfyApp.clipspace.images) {
|
|
const clip_image =
|
|
ComfyApp.clipspace.images[ComfyApp.clipspace['selectedIndex']]
|
|
const index = node.widgets.findIndex((obj) => obj.name === 'image')
|
|
if (index >= 0) {
|
|
if (
|
|
node.widgets[index].type != 'image' &&
|
|
typeof node.widgets[index].value == 'string' &&
|
|
clip_image.filename
|
|
) {
|
|
node.widgets[index].value =
|
|
(clip_image.subfolder ? clip_image.subfolder + '/' : '') +
|
|
clip_image.filename +
|
|
(clip_image.type ? ` [${clip_image.type}]` : '')
|
|
} else {
|
|
node.widgets[index].value = clip_image
|
|
}
|
|
}
|
|
}
|
|
if (ComfyApp.clipspace.widgets && node.widgets) {
|
|
ComfyApp.clipspace.widgets.forEach(({ type, name, value }) => {
|
|
const prop = node.widgets?.find(
|
|
(obj) => obj.type === type && obj.name === name
|
|
)
|
|
if (prop && prop.type != 'button') {
|
|
const valueObj = value as Record<string, unknown> | undefined
|
|
if (
|
|
prop.type != 'image' &&
|
|
typeof prop.value == 'string' &&
|
|
valueObj?.filename
|
|
) {
|
|
const resultItem = value as ResultItem
|
|
prop.value =
|
|
(resultItem.subfolder ? resultItem.subfolder + '/' : '') +
|
|
resultItem.filename +
|
|
(resultItem.type ? ` [${resultItem.type}]` : '')
|
|
} else {
|
|
prop.value = value
|
|
prop.callback?.(value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
app.canvas.setDirty(true)
|
|
|
|
useNodeOutputStore().updateNodeImages(node)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds a handler allowing drag+drop of files onto the window to load workflows
|
|
*/
|
|
private addDropHandler() {
|
|
// Get prompt from dropped PNG or json
|
|
useEventListener(document, 'drop', async (event: DragEvent) => {
|
|
try {
|
|
// Skip if already handled (e.g. file drop onto publish dialog tiles)
|
|
if (event.defaultPrevented) return
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
// graph_mouse is only updated on mousemove, so when files are dragged
|
|
// in from another window the canvas-space cursor is stale. Sync it
|
|
// from the drop event so nodes created below land at the cursor.
|
|
this.canvas.adjustMouseEvent(event)
|
|
this.canvas.graph_mouse[0] = event.canvasX
|
|
this.canvas.graph_mouse[1] = event.canvasY
|
|
|
|
const n = this.dragOverNode
|
|
this.dragOverNode = null
|
|
// Node handles file drop, we dont use the built in onDropFile handler as its buggy
|
|
// If you drag multiple files it will call it multiple times with the same file
|
|
if (await n?.onDragDrop?.(event)) return
|
|
|
|
const files = await extractFilesFromDragEvent(event)
|
|
if (files.length === 0) return
|
|
|
|
const workspace = useWorkspaceStore()
|
|
try {
|
|
workspace.spinner = true
|
|
const imageFiles = files.filter(hasImageType)
|
|
const audioFiles = files.filter(hasAudioType)
|
|
const videoFiles = files.filter(hasVideoType)
|
|
const totalMedia =
|
|
imageFiles.length + audioFiles.length + videoFiles.length
|
|
const hasMultipleMedia = totalMedia > 1
|
|
|
|
if (hasMultipleMedia) {
|
|
if (imageFiles.length > 0) {
|
|
await this.handleFileList(imageFiles)
|
|
}
|
|
if (audioFiles.length > 0) {
|
|
await this.handleAudioFileList(audioFiles)
|
|
}
|
|
if (videoFiles.length > 0) {
|
|
await this.handleVideoFileList(videoFiles)
|
|
}
|
|
for (const file of files.filter((f) => !isMediaFile(f))) {
|
|
await this.handleFile(file, 'file_drop', {
|
|
deferWarnings: true
|
|
})
|
|
}
|
|
} else {
|
|
for (const file of files) {
|
|
await this.handleFile(file, 'file_drop', {
|
|
deferWarnings: true
|
|
})
|
|
}
|
|
}
|
|
} finally {
|
|
workspace.spinner = false
|
|
}
|
|
useWorkflowService().showPendingWarnings()
|
|
} catch (error: unknown) {
|
|
useToastStore().addAlert(t('toastMessages.dropFileError', { error }))
|
|
}
|
|
})
|
|
|
|
// Always clear over node on drag leave
|
|
useEventListener(this.canvasElRef, 'dragleave', async () => {
|
|
if (!this.dragOverNode) return
|
|
this.dragOverNode = null
|
|
this.canvas.setDirty(false, true)
|
|
})
|
|
|
|
// Add handler for dropping onto a specific node
|
|
useEventListener(
|
|
this.canvasElRef,
|
|
'dragover',
|
|
(event: DragEvent) => {
|
|
this.canvas.adjustMouseEvent(event)
|
|
const node = this.canvas.graph?.getNodeOnPos(
|
|
event.canvasX,
|
|
event.canvasY
|
|
)
|
|
|
|
if (!node?.onDragOver?.(event)) {
|
|
this.dragOverNode = null
|
|
return
|
|
}
|
|
|
|
this.dragOverNode = node
|
|
|
|
// dragover event is fired very frequently, run this on an animation frame
|
|
requestAnimationFrame(() => {
|
|
this.canvas.setDirty(false, true)
|
|
})
|
|
},
|
|
false
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Handle keypress
|
|
*/
|
|
private addProcessKeyHandler() {
|
|
const origProcessKey = LGraphCanvas.prototype.processKey
|
|
LGraphCanvas.prototype.processKey = function (e: KeyboardEvent) {
|
|
if (!this.graph) return
|
|
|
|
if (e.target instanceof Element && e.target.localName == 'input') {
|
|
return
|
|
}
|
|
|
|
if (e.type == 'keydown' && !e.repeat) {
|
|
const keyCombo = KeyComboImpl.fromEvent(e)
|
|
const keybindingStore = useKeybindingStore()
|
|
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
|
|
|
if (
|
|
keybinding &&
|
|
keybinding.targetElementId === 'graph-canvas-container'
|
|
) {
|
|
useCommandStore().execute(keybinding.commandId)
|
|
|
|
this.graph.change()
|
|
e.preventDefault()
|
|
e.stopImmediatePropagation()
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fall through to Litegraph defaults
|
|
return origProcessKey.apply(this, [e])
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles updates from the API socket
|
|
*/
|
|
private addApiUpdateHandlers() {
|
|
api.addEventListener('status', ({ detail }) => {
|
|
this.ui.setStatus(detail)
|
|
})
|
|
|
|
api.addEventListener('progress', () => {
|
|
this.canvas.setDirty(true, false)
|
|
})
|
|
|
|
api.addEventListener('executing', () => {
|
|
this.canvas.setDirty(true, false)
|
|
})
|
|
|
|
api.addEventListener('executed', ({ detail }) => {
|
|
const nodeOutputStore = useNodeOutputStore()
|
|
const executionId = String(detail.display_node || detail.node)
|
|
|
|
nodeOutputStore.setNodeOutputsByExecutionId(executionId, detail.output, {
|
|
merge: detail.merge
|
|
})
|
|
|
|
const node = getNodeByExecutionId(this.rootGraph, executionId)
|
|
if (node && node.onExecuted) {
|
|
node.onExecuted(detail.output)
|
|
}
|
|
})
|
|
|
|
api.addEventListener('execution_start', () => {
|
|
triggerCallbackOnAllNodes(this.rootGraph, 'onExecutionStart')
|
|
})
|
|
|
|
api.addEventListener('execution_error', ({ detail }) => {
|
|
// Check if this is an auth-related error or credits-related error
|
|
if (
|
|
detail.exception_message?.includes(
|
|
'Unauthorized: Please login first to use this node.'
|
|
)
|
|
) {
|
|
useDialogService().showApiNodesSignInDialog([detail.node_type])
|
|
} else if (
|
|
detail.exception_message?.includes(
|
|
'Payment Required: Please add credits to your account to use this node.'
|
|
)
|
|
) {
|
|
useDialogService().showTopUpCreditsDialog({
|
|
isInsufficientCredits: true
|
|
})
|
|
} else if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
|
useExecutionErrorStore().showErrorOverlay()
|
|
} else {
|
|
useDialogService().showExecutionErrorDialog(detail)
|
|
}
|
|
this.canvas.draw(true, true)
|
|
})
|
|
|
|
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
|
|
// Enhanced preview with explicit node context
|
|
const { blob, displayNodeId, jobId } = detail
|
|
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
|
|
useNodeOutputStore()
|
|
const blobUrl = createSharedObjectUrl(blob)
|
|
useJobPreviewStore().setPreviewUrl(jobId, blobUrl, displayNodeId)
|
|
// Ensure clean up if `executing` event is missed.
|
|
revokePreviewsByExecutionId(displayNodeId)
|
|
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
|
|
const nodeParents = displayNodeId.split(':')
|
|
for (let i = 1; i <= nodeParents.length; i++) {
|
|
setNodePreviewsByExecutionId(nodeParents.slice(0, i).join(':'), [
|
|
blobUrl
|
|
])
|
|
}
|
|
releaseSharedObjectUrl(blobUrl)
|
|
})
|
|
|
|
api.addEventListener('feature_flags', () => {
|
|
void useNodeReplacementStore().load()
|
|
})
|
|
|
|
api.init()
|
|
}
|
|
|
|
/** Flag that the graph is configuring to prevent nodes from running checks while its still loading */
|
|
private addConfigureHandler() {
|
|
const app = this
|
|
const configure = LGraph.prototype.configure
|
|
LGraph.prototype.configure = function (...args) {
|
|
app.configuringGraphLevel++
|
|
try {
|
|
return configure.apply(this, args)
|
|
} finally {
|
|
app.configuringGraphLevel--
|
|
}
|
|
}
|
|
}
|
|
|
|
private addAfterConfigureHandler(graph: LGraph) {
|
|
const { onConfigure } = graph
|
|
graph.onConfigure = function (...args) {
|
|
// Set pending sync flag to suppress link rendering until slots are synced
|
|
if (LiteGraph.vueNodesMode) {
|
|
layoutStore.setPendingSlotSync(true)
|
|
}
|
|
|
|
try {
|
|
fixLinkInputSlots(this)
|
|
|
|
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
|
|
triggerCallbackOnAllNodes(this, 'onGraphConfigured')
|
|
|
|
const r = onConfigure?.apply(this, args)
|
|
|
|
// Fire after onConfigure, used by primitives to generate widget using input nodes config
|
|
triggerCallbackOnAllNodes(this, 'onAfterGraphConfigured')
|
|
|
|
return r
|
|
} finally {
|
|
// Flush pending slot layout syncs to fix link alignment after undo/redo
|
|
// Using finally ensures links aren't permanently suppressed if an error occurs
|
|
if (LiteGraph.vueNodesMode) {
|
|
flushScheduledSlotLayoutSync()
|
|
app.canvas?.setDirty(true, true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up the app on the page
|
|
*/
|
|
async setup(canvasEl: HTMLCanvasElement) {
|
|
this.bodyTop = document.getElementById('comfyui-body-top')!
|
|
this.bodyLeft = document.getElementById('comfyui-body-left')!
|
|
this.bodyRight = document.getElementById('comfyui-body-right')!
|
|
this.bodyBottom = document.getElementById('comfyui-body-bottom')!
|
|
this.canvasContainer = document.getElementById('graph-canvas-container')!
|
|
|
|
this.canvasElRef.value = canvasEl
|
|
|
|
await useWorkspaceStore().workflow.syncWorkflows()
|
|
//Doesn't need to block. Blueprints will load async
|
|
void useSubgraphStore().fetchSubgraphs()
|
|
await useExtensionService().loadExtensions()
|
|
|
|
this.addProcessKeyHandler()
|
|
this.addConfigureHandler()
|
|
this.addApiUpdateHandlers()
|
|
|
|
const graph = new LGraph()
|
|
|
|
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory
|
|
graph.events.addEventListener('subgraph-created', (e) => {
|
|
try {
|
|
const { subgraph, data } = e.detail
|
|
useSubgraphService().registerNewSubgraph(subgraph, data)
|
|
} catch (err) {
|
|
console.error('Failed to register subgraph', err)
|
|
useToastStore().add({
|
|
severity: 'error',
|
|
summary: 'Failed to register subgraph',
|
|
detail: err instanceof Error ? err.message : String(err)
|
|
})
|
|
}
|
|
})
|
|
|
|
this.addAfterConfigureHandler(graph)
|
|
|
|
this.rootGraphInternal = graph
|
|
installNodeAddedTelemetry(graph)
|
|
this.canvas = new LGraphCanvas(canvasEl, graph)
|
|
// Make canvas states reactive so we can observe changes on them.
|
|
this.canvas.state = reactive(this.canvas.state)
|
|
|
|
this.ctx = canvasEl.getContext('2d')!
|
|
|
|
LiteGraph.alt_drag_do_clone_nodes = true
|
|
LiteGraph.macGesturesRequireMac = false
|
|
|
|
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
|
|
'litegraph:set-graph',
|
|
(e) => {
|
|
const { newGraph } = e.detail
|
|
|
|
const widgetStore = useDomWidgetStore()
|
|
|
|
const activeWidgets: Record<
|
|
string,
|
|
BaseDOMWidget<object | string>
|
|
> = Object.fromEntries(
|
|
newGraph.nodes
|
|
.flatMap((node) => node.widgets ?? [])
|
|
.filter(
|
|
(w) =>
|
|
w instanceof DOMWidgetImpl || w instanceof ComponentWidgetImpl
|
|
)
|
|
.map((w) => [w.id, w])
|
|
)
|
|
|
|
for (const [
|
|
widgetId,
|
|
widgetState
|
|
] of widgetStore.widgetStates.entries()) {
|
|
if (widgetId in activeWidgets) {
|
|
widgetState.active = true
|
|
widgetState.widget = activeWidgets[widgetId]
|
|
} else {
|
|
widgetState.active = false
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
// Ensure subgraphs are scaled when entering them
|
|
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
|
|
'litegraph:set-graph',
|
|
(e) => {
|
|
const { newGraph, oldGraph } = e.detail
|
|
// Only scale when switching between graphs (not during initial setup)
|
|
// oldGraph is null/undefined during initial setup, so skip scaling then
|
|
if (oldGraph) {
|
|
ensureCorrectLayoutScale(
|
|
newGraph.extra.workflowRendererVersion,
|
|
newGraph
|
|
)
|
|
}
|
|
}
|
|
)
|
|
|
|
this.rootGraph.start()
|
|
|
|
// Ensure the canvas fills the window
|
|
useResizeObserver(this.canvasElRef, ([canvasEl]) => {
|
|
if (canvasEl.target instanceof HTMLCanvasElement) {
|
|
this.resizeCanvas(canvasEl.target)
|
|
}
|
|
})
|
|
|
|
await useExtensionService().invokeExtensionsAsync('init')
|
|
await this.registerNodes()
|
|
|
|
this.addDropHandler()
|
|
|
|
await useExtensionService().invokeExtensionsAsync('setup')
|
|
|
|
this.positionConversion = useCanvasPositionConversion(
|
|
this.canvasContainer,
|
|
this.canvas
|
|
)
|
|
}
|
|
|
|
private resizeCanvas(canvas: HTMLCanvasElement) {
|
|
// Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
|
|
const scale = Math.max(window.devicePixelRatio, 1)
|
|
|
|
// Clear fixed width and height while calculating rect so it uses 100% instead
|
|
canvas.height = canvas.width = NaN
|
|
const { width, height } = canvas.getBoundingClientRect()
|
|
canvas.width = Math.round(width * scale)
|
|
canvas.height = Math.round(height * scale)
|
|
canvas.getContext('2d')?.scale(scale, scale)
|
|
this.canvas?.draw(true, true)
|
|
}
|
|
|
|
private updateVueAppNodeDefs(defs: Record<string, ComfyNodeDefV1>) {
|
|
// Frontend only nodes registered by custom nodes.
|
|
// Example: https://github.com/rgthree/rgthree-comfy/blob/dd534e5384be8cf0c0fa35865afe2126ba75ac55/src_web/comfyui/fast_groups_bypasser.ts#L10
|
|
|
|
// Only create frontend_only definitions for nodes that don't have backend definitions
|
|
const frontendOnlyDefs: Record<string, ComfyNodeDefV1> = {}
|
|
for (const [name, node] of Object.entries(
|
|
LiteGraph.registered_node_types
|
|
)) {
|
|
// Skip if we already have a backend definition or system definition
|
|
if (name in defs || name in SYSTEM_NODE_DEFS || node.skip_list) {
|
|
continue
|
|
}
|
|
|
|
frontendOnlyDefs[name] = {
|
|
name,
|
|
display_name: name,
|
|
category: node.category || '__frontend_only__',
|
|
input: { required: {}, optional: {} },
|
|
output: [],
|
|
output_name: [],
|
|
output_is_list: [],
|
|
output_node: false,
|
|
python_module: 'custom_nodes.frontend_only',
|
|
description: node.description ?? `Frontend only node for ${name}`
|
|
} as ComfyNodeDefV1
|
|
}
|
|
|
|
const allNodeDefs = {
|
|
...frontendOnlyDefs,
|
|
...defs,
|
|
...SYSTEM_NODE_DEFS
|
|
}
|
|
|
|
const nodeDefStore = useNodeDefStore()
|
|
const nodeDefArray: ComfyNodeDefV1[] = Object.values(allNodeDefs)
|
|
useExtensionService().invokeExtensions(
|
|
'beforeRegisterVueAppNodeDefs',
|
|
nodeDefArray
|
|
)
|
|
nodeDefStore.updateNodeDefs(nodeDefArray)
|
|
}
|
|
|
|
async getNodeDefs(): Promise<Record<string, ComfyNodeDefV1>> {
|
|
const translateNodeDef = (def: ComfyNodeDefV1): ComfyNodeDefV1 => {
|
|
// Use object info display_name as fallback before using name
|
|
const objectInfoDisplayName = def.display_name || def.name
|
|
|
|
return {
|
|
...def,
|
|
display_name: st(
|
|
`nodeDefs.${def.name}.display_name`,
|
|
objectInfoDisplayName
|
|
),
|
|
description: def.description
|
|
? st(`nodeDefs.${def.name}.description`, def.description)
|
|
: '',
|
|
category: def.category
|
|
.split('/')
|
|
.map((category: string) => st(`nodeCategories.${category}`, category))
|
|
.join('/')
|
|
}
|
|
}
|
|
|
|
return _.mapValues(await api.getNodeDefs(), (def) => translateNodeDef(def))
|
|
}
|
|
|
|
/**
|
|
* Registers nodes with the graph
|
|
*/
|
|
async registerNodes() {
|
|
// Load node definitions from the backend
|
|
const defs = await this.getNodeDefs()
|
|
await this.registerNodesFromDefs(defs)
|
|
await useExtensionService().invokeExtensionsAsync('registerCustomNodes')
|
|
if (this.vueAppReady) {
|
|
this.updateVueAppNodeDefs(defs)
|
|
}
|
|
}
|
|
|
|
async registerNodeDef(nodeId: string, nodeDef: ComfyNodeDefV1) {
|
|
return await useLitegraphService().registerNodeDef(nodeId, nodeDef)
|
|
}
|
|
|
|
async registerNodesFromDefs(defs: Record<string, ComfyNodeDefV1>) {
|
|
await useExtensionService().invokeExtensionsAsync('addCustomNodeDefs', defs)
|
|
|
|
// Register a node for each definition
|
|
await Promise.all(
|
|
Object.keys(defs).map((nodeId) =>
|
|
this.registerNodeDef(nodeId, defs[nodeId])
|
|
)
|
|
)
|
|
}
|
|
|
|
loadTemplateData(templateData: {
|
|
templates?: { name?: string; data?: string }[]
|
|
}): void {
|
|
if (!templateData?.templates) {
|
|
return
|
|
}
|
|
|
|
const old = localStorage.getItem('litegrapheditor_clipboard')
|
|
|
|
for (const template of templateData.templates) {
|
|
if (!template?.data) {
|
|
continue
|
|
}
|
|
|
|
// Check for old clipboard format
|
|
const data = parseJsonWithNonFinite<{ reroutes?: unknown }>(template.data)
|
|
if (!data.reroutes) {
|
|
deserialiseAndCreate(template.data, app.canvas)
|
|
} else {
|
|
localStorage.setItem('litegrapheditor_clipboard', template.data)
|
|
app.canvas.pasteFromClipboard()
|
|
}
|
|
|
|
// Move mouse position down to paste the next template below
|
|
let maxY: number | undefined
|
|
|
|
for (const i in app.canvas.selected_nodes) {
|
|
const node = app.canvas.selected_nodes[i]
|
|
const nodeBottom = node.pos[1] + node.size[1]
|
|
if (maxY === undefined || nodeBottom > maxY) {
|
|
maxY = nodeBottom
|
|
}
|
|
}
|
|
|
|
if (maxY !== undefined) {
|
|
app.canvas.graph_mouse[1] = maxY + 50
|
|
}
|
|
}
|
|
|
|
if (old !== null) {
|
|
localStorage.setItem('litegrapheditor_clipboard', old)
|
|
}
|
|
}
|
|
|
|
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
|
|
if (useMissingNodesErrorStore().surfaceMissingNodes(missingNodeTypes)) {
|
|
useExecutionErrorStore().showErrorOverlay()
|
|
}
|
|
}
|
|
|
|
async loadGraphData(
|
|
graphData?: ComfyWorkflowJSON,
|
|
clean: boolean = true,
|
|
restore_view: boolean = true,
|
|
workflow: string | null | ComfyWorkflow = null,
|
|
options: {
|
|
checkForRerouteMigration?: boolean
|
|
openSource?: WorkflowOpenSource
|
|
shareId?: string
|
|
deferWarnings?: boolean
|
|
skipAssetScans?: boolean
|
|
silentAssetErrors?: boolean
|
|
} = {}
|
|
) {
|
|
const {
|
|
checkForRerouteMigration = false,
|
|
openSource,
|
|
shareId,
|
|
deferWarnings = false,
|
|
skipAssetScans = false,
|
|
silentAssetErrors = false
|
|
} = options
|
|
useWorkflowService().beforeLoadNewGraph()
|
|
|
|
if (skipAssetScans) {
|
|
// Only reset candidates; preserve UI state (fileSizes, etc.)
|
|
// so cached results restored by showPendingWarnings still display sizes.
|
|
// Abort any in-flight verification from the outgoing workflow so a late
|
|
// result cannot repopulate the store after we've switched workflows.
|
|
useMissingModelStore().createVerificationAbortController().abort()
|
|
useMissingMediaStore().createVerificationAbortController().abort()
|
|
useMissingModelStore().setMissingModels([])
|
|
useMissingMediaStore().setMissingMedia([])
|
|
} else {
|
|
useMissingModelStore().clearMissingModels()
|
|
useMissingMediaStore().clearMissingMedia()
|
|
}
|
|
|
|
if (clean !== false) {
|
|
// Reset canvas context before configuring a new graph so subgraph UI
|
|
// state from the previous workflow cannot leak into the newly loaded
|
|
// one, and so `clean()` can clear the root graph even when the user is
|
|
// currently inside a subgraph.
|
|
this.canvas.setGraph(this.rootGraph)
|
|
|
|
this.clean()
|
|
}
|
|
|
|
let reset_invalid_values = false
|
|
// Use explicit validation instead of falsy check to avoid replacing
|
|
// valid but falsy values (empty objects, 0, false, etc.)
|
|
if (
|
|
!graphData ||
|
|
typeof graphData !== 'object' ||
|
|
Array.isArray(graphData)
|
|
) {
|
|
graphData = defaultGraph
|
|
reset_invalid_values = true
|
|
}
|
|
|
|
graphData = clone(graphData)
|
|
|
|
if (useSettingStore().get('Comfy.Validation.Workflows')) {
|
|
const { graphData: validatedGraphData } =
|
|
await useWorkflowValidation().validateWorkflow(graphData)
|
|
|
|
// If the validation failed, use the original graph data.
|
|
// Ideally we should not block users from loading the workflow.
|
|
graphData = validatedGraphData ?? graphData
|
|
}
|
|
// Only show the reroute migration warning if the workflow does not have native
|
|
// reroutes. Merging reroute network has great complexity, and it is not supported
|
|
// for now.
|
|
// See: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3317
|
|
if (
|
|
checkForRerouteMigration &&
|
|
graphData.version === 0.4 &&
|
|
findLegacyRerouteNodes(graphData).length &&
|
|
noNativeReroutes(graphData)
|
|
) {
|
|
useToastStore().add({
|
|
group: 'reroute-migration',
|
|
severity: 'warn'
|
|
})
|
|
}
|
|
useSubgraphService().loadSubgraphs(graphData)
|
|
|
|
const missingNodeTypes: MissingNodeType[] = []
|
|
await useExtensionService().invokeExtensionsAsync(
|
|
'beforeConfigureGraph',
|
|
graphData,
|
|
missingNodeTypes
|
|
)
|
|
|
|
const nodeReplacementStore = useNodeReplacementStore()
|
|
await nodeReplacementStore.load()
|
|
|
|
// Collect missing node types from all nodes (root + subgraphs)
|
|
const collectMissingNodes = (
|
|
nodes: readonly FlattenableWorkflowNode[],
|
|
pathPrefix: string = '',
|
|
displayName: string = ''
|
|
) => {
|
|
if (!Array.isArray(nodes)) {
|
|
console.warn(
|
|
'Workflow nodes data is missing or invalid, skipping node processing',
|
|
{ nodes, pathPrefix }
|
|
)
|
|
return
|
|
}
|
|
for (let n of nodes) {
|
|
if (!(n.type in LiteGraph.registered_node_types)) {
|
|
// Always sanitize so configure() can handle unregistered types,
|
|
// but only report as missing if the node is active.
|
|
const isMuted =
|
|
n.mode === LGraphEventMode.NEVER ||
|
|
n.mode === LGraphEventMode.BYPASS
|
|
if (!isMuted) {
|
|
const replacement = nodeReplacementStore.getReplacementFor(n.type)
|
|
const cnrId = getCnrIdFromProperties(
|
|
n.properties as Record<string, unknown> | undefined
|
|
)
|
|
const executionId = pathPrefix
|
|
? `${pathPrefix}:${n.id}`
|
|
: String(n.id)
|
|
|
|
missingNodeTypes.push({
|
|
type: n.type,
|
|
nodeId: executionId,
|
|
cnrId,
|
|
...(displayName && {
|
|
hint: t('g.inSubgraph', { name: displayName })
|
|
}),
|
|
isReplaceable: replacement !== null,
|
|
replacement: replacement ?? undefined
|
|
})
|
|
}
|
|
|
|
n.type = sanitizeNodeName(n.type)
|
|
}
|
|
}
|
|
}
|
|
|
|
collectMissingNodes(graphData.nodes)
|
|
const subgraphDefs = collectSubgraphDefinitions(
|
|
graphData.definitions?.subgraphs ?? []
|
|
)
|
|
const subgraphContainerIdMap = buildSubgraphExecutionPaths(
|
|
graphData.nodes,
|
|
subgraphDefs
|
|
)
|
|
for (const subgraph of subgraphDefs) {
|
|
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
|
|
for (const pathPrefix of paths) {
|
|
collectMissingNodes(
|
|
subgraph.nodes,
|
|
pathPrefix,
|
|
subgraph.name || subgraph.id
|
|
)
|
|
}
|
|
}
|
|
|
|
const canvasVisible = !!(this.canvasEl.width && this.canvasEl.height)
|
|
const fitView = () => {
|
|
if (
|
|
restore_view &&
|
|
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
|
|
) {
|
|
// Always fit view for templates to ensure they're visible on load
|
|
if (openSource === 'template') {
|
|
useLitegraphService().fitView()
|
|
} else if (graphData.extra?.ds) {
|
|
this.canvas.ds.offset = graphData.extra.ds.offset
|
|
this.canvas.ds.scale = graphData.extra.ds.scale
|
|
|
|
// Fit view if no nodes visible in restored viewport
|
|
this.canvas.ds.computeVisibleArea(this.canvas.viewport)
|
|
if (
|
|
this.canvas.visible_area.width &&
|
|
this.canvas.visible_area.height &&
|
|
!anyItemOverlapsRect(
|
|
this.rootGraph._nodes,
|
|
this.canvas.visible_area
|
|
)
|
|
) {
|
|
requestAnimationFrame(() => useLitegraphService().fitView())
|
|
}
|
|
} else {
|
|
useLitegraphService().fitView()
|
|
}
|
|
}
|
|
}
|
|
|
|
ChangeTracker.isLoadingGraph = true
|
|
try {
|
|
let normalizedMainGraph = false
|
|
try {
|
|
// @ts-expect-error Discrepancies between zod and litegraph - in progress
|
|
this.rootGraph.configure(graphData)
|
|
|
|
// Save original renderer version before scaling (it gets modified during scaling)
|
|
const originalMainGraphRenderer =
|
|
this.rootGraph.extra.workflowRendererVersion
|
|
|
|
// Scale main graph
|
|
normalizedMainGraph = ensureCorrectLayoutScale(
|
|
originalMainGraphRenderer,
|
|
this.rootGraph
|
|
)
|
|
|
|
// Scale all subgraphs that were loaded with the workflow
|
|
// Use original main graph renderer as fallback (not the modified one)
|
|
for (const subgraph of this.rootGraph.subgraphs.values()) {
|
|
ensureCorrectLayoutScale(
|
|
subgraph.extra.workflowRendererVersion || originalMainGraphRenderer,
|
|
subgraph
|
|
)
|
|
}
|
|
|
|
if (canvasVisible) fitView()
|
|
} catch (error) {
|
|
useDialogService().showErrorDialog(error, {
|
|
title: t('errorDialog.loadWorkflowTitle'),
|
|
reportType: 'loadWorkflowError'
|
|
})
|
|
console.error(error)
|
|
return
|
|
}
|
|
const snapTo = LiteGraph.alwaysSnapToGrid
|
|
? this.rootGraph.getSnapToGridSize()
|
|
: 0
|
|
forEachNode(this.rootGraph, (node) => {
|
|
const size = node.computeSize()
|
|
size[0] = Math.max(node.size[0], size[0])
|
|
size[1] = Math.max(node.size[1], size[1])
|
|
snapPoint(size, snapTo, 'ceil')
|
|
node.setSize(size)
|
|
if (node.widgets) {
|
|
// If you break something in the backend and want to patch workflows in the frontend
|
|
// This is the place to do this
|
|
for (let widget of node.widgets) {
|
|
if (node.type == 'KSampler' || node.type == 'KSamplerAdvanced') {
|
|
if (widget.name == 'sampler_name') {
|
|
if (
|
|
typeof widget.value === 'string' &&
|
|
widget.value.startsWith('sample_')
|
|
) {
|
|
widget.value = widget.value.slice(7)
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
node.type == 'KSampler' ||
|
|
node.type == 'KSamplerAdvanced' ||
|
|
node.type == 'PrimitiveNode'
|
|
) {
|
|
if (widget.name == 'control_after_generate') {
|
|
if (widget.value === true) {
|
|
widget.value = 'randomize'
|
|
} else if (widget.value === false) {
|
|
widget.value = 'fixed'
|
|
}
|
|
}
|
|
}
|
|
if (widget.type == 'combo') {
|
|
const values = widget.options.values as
|
|
| (string | number | boolean)[]
|
|
| undefined
|
|
if (
|
|
values &&
|
|
values.length > 0 &&
|
|
(widget.value == null ||
|
|
(reset_invalid_values &&
|
|
!values.includes(
|
|
widget.value as string | number | boolean
|
|
)))
|
|
) {
|
|
widget.value = values[0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
useExtensionService().invokeExtensions('loadedGraphNode', node)
|
|
})
|
|
|
|
if (normalizedMainGraph) {
|
|
syncLayoutStoreNodeBoundsFromGraph(this.rootGraph)
|
|
}
|
|
|
|
await useExtensionService().invokeExtensionsAsync(
|
|
'afterConfigureGraph',
|
|
missingNodeTypes
|
|
)
|
|
|
|
const effectiveShareId =
|
|
shareId ??
|
|
(workflow instanceof ComfyWorkflow ? workflow.shareId : undefined)
|
|
const telemetryPayload = {
|
|
missing_node_count: missingNodeTypes.length,
|
|
missing_node_types: missingNodeTypes.map((node) =>
|
|
typeof node === 'string' ? node : node.type
|
|
),
|
|
missing_node_packs: groupMissingNodesByPack(missingNodeTypes),
|
|
open_source: openSource ?? 'unknown',
|
|
...(effectiveShareId ? { share_id: effectiveShareId } : {})
|
|
}
|
|
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
|
|
useTelemetry()?.trackWorkflowImported(telemetryPayload)
|
|
await useWorkflowService().afterLoadNewGraph(
|
|
workflow,
|
|
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON,
|
|
effectiveShareId
|
|
)
|
|
|
|
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view
|
|
// This fixes switching from app mode to a new graph mode workflow (e.g. load template)
|
|
if (!canvasVisible && (!workflow || typeof workflow === 'string')) {
|
|
this.canvas.resize()
|
|
requestAnimationFrame(() => fitView())
|
|
}
|
|
|
|
// Drop missing-node entries whose enclosing subgraph is
|
|
// muted/bypassed. The initial JSON scan only checks each node's
|
|
// own mode; the cascade from an inactive container is applied here
|
|
// using the now-configured live graph.
|
|
const activeMissingNodeTypes = missingNodeTypes.filter(
|
|
(n) =>
|
|
typeof n === 'string' ||
|
|
n.nodeId == null ||
|
|
isAncestorPathActive(this.rootGraph, String(n.nodeId))
|
|
)
|
|
|
|
if (!skipAssetScans) {
|
|
await runMissingModelPipeline({
|
|
graph: this.rootGraph,
|
|
graphData,
|
|
missingModelStore: useMissingModelStore(),
|
|
missingNodeTypes: activeMissingNodeTypes,
|
|
silent: silentAssetErrors
|
|
})
|
|
|
|
await this.runMissingMediaPipeline(silentAssetErrors)
|
|
}
|
|
|
|
if (!deferWarnings) {
|
|
useWorkflowService().showPendingWarnings(undefined, {
|
|
silent: silentAssetErrors
|
|
})
|
|
}
|
|
|
|
void useSubgraphNavigationStore().updateHash()
|
|
requestAnimationFrame(() => {
|
|
this.canvas.setDirty(true, true)
|
|
})
|
|
} finally {
|
|
ChangeTracker.isLoadingGraph = false
|
|
}
|
|
}
|
|
|
|
async refreshMissingModels(
|
|
options: { silent?: boolean } = {}
|
|
): Promise<MissingModelPipelineResult> {
|
|
return refreshMissingModelPipeline({
|
|
graph: this.rootGraph,
|
|
reloadNodeDefs: () => this.reloadNodeDefs(),
|
|
missingModelStore: useMissingModelStore(),
|
|
silent: options.silent ?? true
|
|
})
|
|
}
|
|
|
|
private cacheMediaCandidates(
|
|
wf: ComfyWorkflow | null,
|
|
confirmed: MissingMediaCandidate[]
|
|
) {
|
|
if (!wf) return
|
|
updatePendingWarnings(wf, {
|
|
missingMediaCandidates: confirmed
|
|
})
|
|
}
|
|
|
|
private async runMissingMediaPipeline(
|
|
silent: boolean = false
|
|
): Promise<void> {
|
|
const missingMediaStore = useMissingMediaStore()
|
|
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
|
const allCandidates = scanAllMediaCandidates(this.rootGraph, isCloud)
|
|
// Drop candidates whose enclosing subgraph is muted/bypassed.
|
|
const candidates = allCandidates.filter((c) =>
|
|
isAncestorPathActive(this.rootGraph, String(c.nodeId))
|
|
)
|
|
|
|
if (!candidates.length) {
|
|
this.cacheMediaCandidates(activeWf, [])
|
|
return
|
|
}
|
|
|
|
const pending = candidates.some((c) => c.isMissing === undefined)
|
|
if (pending) {
|
|
const controller = missingMediaStore.createVerificationAbortController()
|
|
void verifyMediaCandidates(candidates, {
|
|
isCloud,
|
|
signal: controller.signal
|
|
})
|
|
.then(() => {
|
|
if (controller.signal.aborted) return
|
|
// Re-check ancestor after async verification (see model pipeline).
|
|
const confirmed = candidates.filter((c) =>
|
|
isMissingCandidateActive(this.rootGraph, c)
|
|
)
|
|
if (confirmed.length) {
|
|
useExecutionErrorStore().surfaceMissingMedia(confirmed, { silent })
|
|
}
|
|
this.cacheMediaCandidates(activeWf, confirmed)
|
|
})
|
|
.catch((err) => {
|
|
console.warn(
|
|
'[Missing Media Pipeline] Asset verification failed:',
|
|
err
|
|
)
|
|
useToastStore().add({
|
|
severity: 'warn',
|
|
summary: st(
|
|
'toastMessages.missingMediaVerificationFailed',
|
|
'Failed to verify missing media. Some inputs may not be shown in the Errors tab.'
|
|
),
|
|
life: 5000
|
|
})
|
|
})
|
|
} else {
|
|
const confirmed = candidates.filter((c) => c.isMissing === true)
|
|
if (confirmed.length) {
|
|
useExecutionErrorStore().surfaceMissingMedia(confirmed, { silent })
|
|
}
|
|
this.cacheMediaCandidates(activeWf, confirmed)
|
|
}
|
|
}
|
|
|
|
async graphToPrompt(graph = this.rootGraph) {
|
|
return graphToPrompt(graph, {
|
|
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
|
|
})
|
|
}
|
|
|
|
async queuePrompt(
|
|
number: number,
|
|
batchCount: number = 1,
|
|
queueNodeIds?: NodeExecutionId[]
|
|
): Promise<boolean> {
|
|
const requestId = this.nextQueueRequestId++
|
|
this.queueItems.push({ number, batchCount, queueNodeIds, requestId })
|
|
api.dispatchCustomEvent('promptQueueing', {
|
|
requestId,
|
|
batchCount
|
|
})
|
|
|
|
// Only have one action process the items so each one gets a unique seed correctly
|
|
if (this.processingQueue) {
|
|
return false
|
|
}
|
|
|
|
this.processingQueue = true
|
|
const executionStore = useExecutionStore()
|
|
const executionErrorStore = useExecutionErrorStore()
|
|
executionErrorStore.clearAllErrors()
|
|
|
|
// Get auth token for backend nodes - uses workspace token if enabled, otherwise Firebase token
|
|
const comfyOrgAuthToken = await useAuthStore().getAuthToken()
|
|
const comfyOrgApiKey = useApiKeyAuthStore().getApiKey()
|
|
|
|
try {
|
|
while (this.queueItems.length) {
|
|
const { number, batchCount, queueNodeIds, requestId } =
|
|
this.queueItems.pop()!
|
|
let queuedCount = 0
|
|
const previewMethod = useSettingStore().get(
|
|
'Comfy.Execution.PreviewMethod'
|
|
)
|
|
|
|
const isPartialExecution = !!queueNodeIds?.length
|
|
for (let i = 0; i < batchCount; i++) {
|
|
// Allow widgets to run callbacks before a prompt has been queued
|
|
// e.g. random seed before every gen
|
|
forEachNode(this.rootGraph, (node) => {
|
|
for (const widget of node.widgets ?? []) {
|
|
widget.beforeQueued?.({ isPartialExecution })
|
|
}
|
|
})
|
|
|
|
// Capture workflow before await — activeWorkflow may change if the
|
|
// user switches tabs while the request is in flight.
|
|
const queuedWorkflow = useWorkspaceStore().workflow
|
|
.activeWorkflow as ComfyWorkflow
|
|
const p = await this.graphToPrompt(this.rootGraph)
|
|
const queuedNodes = collectAllNodes(this.rootGraph)
|
|
try {
|
|
api.authToken = comfyOrgAuthToken
|
|
api.apiKey = comfyOrgApiKey ?? undefined
|
|
const res = await api.queuePrompt(number, p, {
|
|
partialExecutionTargets: queueNodeIds,
|
|
previewMethod
|
|
})
|
|
delete api.authToken
|
|
delete api.apiKey
|
|
const nodeErrors = res.node_errors
|
|
const hasNodeErrors =
|
|
nodeErrors && Object.keys(nodeErrors).length > 0
|
|
executionErrorStore.lastNodeErrors = hasNodeErrors
|
|
? nodeErrors
|
|
: null
|
|
try {
|
|
if (res.prompt_id) {
|
|
executionStore.storeJob({
|
|
id: res.prompt_id,
|
|
nodes: Object.keys(p.output),
|
|
promptOutput: p.output,
|
|
workflow: queuedWorkflow
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to store queued job metadata', {
|
|
promptId: res.prompt_id,
|
|
error
|
|
})
|
|
}
|
|
if (hasNodeErrors) {
|
|
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
|
executionErrorStore.showErrorOverlay()
|
|
}
|
|
this.canvas.draw(true, true)
|
|
}
|
|
} catch (error: unknown) {
|
|
if (
|
|
error instanceof PromptExecutionError &&
|
|
typeof error.response.error === 'object' &&
|
|
error.response.error?.type === 'missing_node_type'
|
|
) {
|
|
// Re-scan the full graph instead of using the server's single-node response.
|
|
rescanAndSurfaceMissingNodes(this.rootGraph)
|
|
} else if (
|
|
error instanceof PromptExecutionError &&
|
|
error.status === 403
|
|
) {
|
|
// User is authenticated but not authorized (e.g. not whitelisted).
|
|
// Show a clear message instead of a generic error or sign-in prompt.
|
|
// The response may be middleware JSON {"message": "..."} or the
|
|
// standard {"error": {"message": "..."}} shape, so check both.
|
|
const raw =
|
|
error.response && typeof error.response === 'object'
|
|
? (error.response as Record<string, unknown>)
|
|
: {}
|
|
const rawError =
|
|
raw.error && typeof raw.error === 'object'
|
|
? (raw.error as Record<string, unknown>)
|
|
: undefined
|
|
const detail =
|
|
typeof raw.message === 'string'
|
|
? raw.message
|
|
: typeof rawError?.message === 'string'
|
|
? rawError.message
|
|
: typeof raw.error === 'string'
|
|
? raw.error
|
|
: t('errorDialog.accessRestrictedMessage')
|
|
useDialogService().showErrorDialog(new Error(detail), {
|
|
title: t('errorDialog.accessRestrictedTitle'),
|
|
reportType: 'accessRestrictedError'
|
|
})
|
|
} else if (
|
|
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
|
|
!(error instanceof PromptExecutionError)
|
|
) {
|
|
useDialogService().showErrorDialog(error, {
|
|
title: t('errorDialog.promptExecutionError'),
|
|
reportType: 'promptExecutionError'
|
|
})
|
|
}
|
|
console.error(error)
|
|
|
|
if (error instanceof PromptExecutionError) {
|
|
executionErrorStore.lastNodeErrors =
|
|
error.response.node_errors ?? null
|
|
|
|
// Store prompt-level error separately only when no node-specific errors exist,
|
|
// because node errors already carry the full context. Prompt-level errors
|
|
// (e.g. prompt_no_outputs, no_prompt) lack node IDs and need their own path.
|
|
const nodeErrors = error.response.node_errors
|
|
const hasNodeErrors =
|
|
nodeErrors && Object.keys(nodeErrors).length > 0
|
|
|
|
if (!hasNodeErrors) {
|
|
const respError = error.response.error
|
|
if (respError && typeof respError === 'object') {
|
|
executionErrorStore.lastPromptError = {
|
|
type: respError.type,
|
|
message: respError.message,
|
|
details: respError.details ?? ''
|
|
}
|
|
} else if (typeof respError === 'string') {
|
|
executionErrorStore.lastPromptError = {
|
|
type: 'error',
|
|
message: respError,
|
|
details: ''
|
|
}
|
|
}
|
|
}
|
|
|
|
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
|
executionErrorStore.showErrorOverlay()
|
|
}
|
|
this.canvas.draw(true, true)
|
|
}
|
|
break
|
|
}
|
|
|
|
queuedCount++
|
|
|
|
// Allow widgets to run callbacks after a prompt has been queued
|
|
// e.g. random seed after every gen
|
|
executeWidgetsCallback(queuedNodes, 'afterQueued', {
|
|
isPartialExecution
|
|
})
|
|
this.canvas.draw(true, true)
|
|
await this.ui.queue.update()
|
|
}
|
|
|
|
if (queuedCount > 0) {
|
|
api.dispatchCustomEvent('promptQueued', {
|
|
number,
|
|
batchCount: queuedCount,
|
|
requestId
|
|
})
|
|
}
|
|
}
|
|
} finally {
|
|
this.processingQueue = false
|
|
}
|
|
return !executionErrorStore.lastNodeErrors
|
|
}
|
|
|
|
showErrorOnFileLoad(file: File) {
|
|
useToastStore().addAlert(
|
|
t('toastMessages.fileLoadError', { fileName: file.name })
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Loads workflow data from the specified file
|
|
* @param {File} file
|
|
*/
|
|
async handleFile(
|
|
file: File,
|
|
openSource?: WorkflowOpenSource,
|
|
options?: { deferWarnings?: boolean }
|
|
) {
|
|
const fileName = file.name.replace(/\.\w+$/, '') // Strip file extension
|
|
const workflowData = await getWorkflowDataFromFile(file)
|
|
const { workflow, prompt, parameters, templates } = workflowData ?? {}
|
|
|
|
if (!(workflow || prompt || parameters || templates)) {
|
|
const mediaNodeTypes: Record<string, [string, typeof pasteImageNode]> = {
|
|
image: ['LoadImage', pasteImageNode],
|
|
audio: ['LoadAudio', pasteAudioNode],
|
|
video: ['LoadVideo', pasteVideoNode]
|
|
}
|
|
|
|
const mediaType = Object.keys(mediaNodeTypes).find((t) =>
|
|
file.type.startsWith(t)
|
|
)
|
|
if (mediaType) {
|
|
const [nodeType, pasteFn] = mediaNodeTypes[mediaType]
|
|
const transfer = new DataTransfer()
|
|
transfer.items.add(file)
|
|
const node = await createNode(this.canvas, nodeType)
|
|
await pasteFn(this.canvas, transfer.items, node)
|
|
return
|
|
}
|
|
|
|
this.showErrorOnFileLoad(file)
|
|
return
|
|
}
|
|
|
|
if (
|
|
templates &&
|
|
typeof templates === 'object' &&
|
|
Array.isArray(templates)
|
|
) {
|
|
this.loadTemplateData({
|
|
templates: templates as { name?: string; data?: string }[]
|
|
})
|
|
}
|
|
|
|
// Check workflow first - it should take priority over parameters
|
|
// when both are present (e.g., in ComfyUI-generated PNGs)
|
|
if (workflow) {
|
|
let workflowObj: ComfyWorkflowJSON | undefined = undefined
|
|
try {
|
|
workflowObj =
|
|
typeof workflow === 'string'
|
|
? parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow)
|
|
: (workflow as ComfyWorkflowJSON)
|
|
|
|
// Only load workflow if parsing succeeded AND validation passed
|
|
if (
|
|
workflowObj &&
|
|
typeof workflowObj === 'object' &&
|
|
!Array.isArray(workflowObj)
|
|
) {
|
|
await this.loadGraphData(workflowObj, true, true, fileName, {
|
|
openSource,
|
|
deferWarnings: options?.deferWarnings
|
|
})
|
|
return
|
|
} else {
|
|
console.error(
|
|
'Invalid workflow structure, trying parameters fallback'
|
|
)
|
|
this.showErrorOnFileLoad(file)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to parse workflow:', err)
|
|
this.showErrorOnFileLoad(file)
|
|
// Fall through to check parameters as fallback
|
|
}
|
|
}
|
|
|
|
if (prompt) {
|
|
try {
|
|
const promptObj =
|
|
typeof prompt === 'string'
|
|
? parseJsonWithNonFinite<ComfyApiWorkflow>(prompt)
|
|
: prompt
|
|
if (this.isApiJson(promptObj)) {
|
|
this.loadApiJson(promptObj, fileName)
|
|
return
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to parse prompt:', err)
|
|
}
|
|
// Fall through to parameters as a last resort
|
|
}
|
|
|
|
// Use parameters strictly as the final fallback
|
|
if (parameters && typeof parameters === 'string') {
|
|
useWorkflowService().beforeLoadNewGraph()
|
|
importA1111(this.rootGraph, parameters)
|
|
useWorkflowService().afterLoadNewGraph(
|
|
fileName,
|
|
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
|
|
)
|
|
return
|
|
}
|
|
|
|
this.showErrorOnFileLoad(file)
|
|
}
|
|
|
|
/**
|
|
* Loads multiple files, connects to a batch node, and selects them
|
|
* @param {FileList} fileList
|
|
*/
|
|
async handleFileList(fileList: File[]) {
|
|
if (fileList.length === 0) return
|
|
if (!fileList[0].type.startsWith('image')) return
|
|
|
|
const imageNodes = await pasteImageNodes(this.canvas, fileList)
|
|
if (imageNodes.length === 0) return
|
|
|
|
if (imageNodes.length > 1) {
|
|
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
|
|
if (!batchImagesNode) return
|
|
|
|
this.positionBatchNodes(imageNodes, batchImagesNode)
|
|
this.canvas.selectItems([...imageNodes, batchImagesNode])
|
|
|
|
imageNodes.forEach((imageNode, index) => {
|
|
imageNode.connect(0, batchImagesNode, index)
|
|
})
|
|
} else {
|
|
this.canvas.selectItems(imageNodes)
|
|
}
|
|
}
|
|
|
|
async handleAudioFileList(fileList: File[]) {
|
|
const audioNodes = await pasteAudioNodes(this.canvas, fileList)
|
|
if (audioNodes.length === 0) return
|
|
|
|
this.positionNodes(audioNodes)
|
|
this.canvas.selectItems(audioNodes)
|
|
}
|
|
|
|
async handleVideoFileList(fileList: File[]) {
|
|
const videoNodes = await pasteVideoNodes(this.canvas, fileList)
|
|
if (videoNodes.length === 0) return
|
|
|
|
this.positionNodes(videoNodes)
|
|
this.canvas.selectItems(videoNodes)
|
|
}
|
|
|
|
/**
|
|
* Positions batched nodes in drag and drop
|
|
* @param nodes
|
|
* @param batchNode
|
|
*/
|
|
positionNodes(nodes: LGraphNode[]): void {
|
|
if (nodes.length <= 1) return
|
|
|
|
const [x, y] = nodes[0].getBounding()
|
|
const nodeHeight = 150
|
|
|
|
nodes.forEach((node, index) => {
|
|
if (index > 0) {
|
|
node.pos = [x, y + nodeHeight * index + 25 * (index + 1)]
|
|
}
|
|
})
|
|
|
|
this.canvas.graph?.change()
|
|
}
|
|
|
|
positionBatchNodes(nodes: LGraphNode[], batchNode: LGraphNode): void {
|
|
const [x, y, width] = nodes[0].getBounding()
|
|
batchNode.pos = [x + width + 100, y + 30]
|
|
|
|
// Retrieving Node Height is inconsistent
|
|
let height = 0
|
|
if (nodes[0].type === 'LoadImage') {
|
|
height = 344
|
|
}
|
|
|
|
nodes.forEach((node, index) => {
|
|
if (index > 0) {
|
|
node.pos = [x, y + height * index + 25 * (index + 1)]
|
|
}
|
|
})
|
|
|
|
this.canvas.graph?.change()
|
|
}
|
|
|
|
// @deprecated
|
|
isApiJson(data: unknown): data is ComfyApiWorkflow {
|
|
if (!_.isObject(data) || Array.isArray(data)) {
|
|
return false
|
|
}
|
|
if (Object.keys(data).length === 0) return false
|
|
|
|
return Object.values(data).every((node) => {
|
|
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
|
return false
|
|
}
|
|
|
|
const { class_type: classType, inputs } = node as Record<string, unknown>
|
|
const inputsIsRecord = _.isObject(inputs) && !Array.isArray(inputs)
|
|
return typeof classType === 'string' && inputsIsRecord
|
|
})
|
|
}
|
|
|
|
loadApiJson(apiData: ComfyApiWorkflow, fileName: string) {
|
|
useWorkflowService().beforeLoadNewGraph()
|
|
|
|
const missingNodeTypes = Object.values(apiData).filter(
|
|
(n) => !LiteGraph.registered_node_types[n.class_type]
|
|
)
|
|
if (missingNodeTypes.length) {
|
|
this.showMissingNodesError(missingNodeTypes.map((t) => t.class_type))
|
|
}
|
|
|
|
const ids = Object.keys(apiData)
|
|
app.rootGraph.clear()
|
|
for (const id of ids) {
|
|
const data = apiData[id]
|
|
const node = LiteGraph.createNode(data.class_type)
|
|
if (!node) continue
|
|
node.id = isNaN(+id) ? id : +id
|
|
node.title = data._meta?.title ?? node.title
|
|
app.rootGraph.add(node)
|
|
}
|
|
|
|
const processNodeInputs = (id: string) => {
|
|
const data = apiData[id]
|
|
const node = app.rootGraph.getNodeById(id)
|
|
if (!node) return
|
|
|
|
for (const input in data.inputs ?? {}) {
|
|
const value = data.inputs[input]
|
|
if (value instanceof Array) {
|
|
const [fromId, fromSlot] = value
|
|
const fromNode = app.rootGraph.getNodeById(fromId)
|
|
if (!fromNode) continue
|
|
|
|
let toSlot = node.inputs?.findIndex((inp) => inp.name === input) ?? -1
|
|
if (toSlot === -1) {
|
|
try {
|
|
const widget = node.widgets?.find((w) => w.name === input)
|
|
const convertFn = (
|
|
node as LGraphNode & {
|
|
convertWidgetToInput?: (w: IBaseWidget) => boolean
|
|
}
|
|
).convertWidgetToInput
|
|
if (widget && convertFn?.(widget)) {
|
|
// Re-find the target slot by name after conversion
|
|
toSlot =
|
|
node.inputs?.findIndex((inp) => inp.name === input) ?? -1
|
|
}
|
|
} catch (_error) {
|
|
// Ignore conversion errors
|
|
}
|
|
}
|
|
if (toSlot !== -1) {
|
|
fromNode.connect(fromSlot, node, toSlot)
|
|
}
|
|
} else {
|
|
const widget = node.widgets?.find((w) => w.name === input)
|
|
if (widget) {
|
|
widget.value = value
|
|
widget.callback?.(value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const id of ids) processNodeInputs(id)
|
|
app.rootGraph.arrange()
|
|
for (const id of ids) processNodeInputs(id)
|
|
app.rootGraph.arrange()
|
|
|
|
useWorkflowService().afterLoadNewGraph(
|
|
fileName,
|
|
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Registers a Comfy web extension with the app
|
|
* @param {ComfyExtension} extension
|
|
*/
|
|
registerExtension(extension: ComfyExtension) {
|
|
useExtensionService().registerExtension(extension)
|
|
}
|
|
|
|
/**
|
|
* Collects context menu items from all extensions for canvas menus
|
|
* @param canvas The canvas instance
|
|
* @returns Array of context menu items from all extensions
|
|
*/
|
|
collectCanvasMenuItems(canvas: LGraphCanvas): IContextMenuValue[] {
|
|
return useExtensionService()
|
|
.invokeExtensions('getCanvasMenuItems', canvas)
|
|
.flat() as IContextMenuValue[]
|
|
}
|
|
|
|
/**
|
|
* Collects context menu items from all extensions for node menus
|
|
* @param node The node being right-clicked
|
|
* @returns Array of context menu items from all extensions
|
|
*/
|
|
collectNodeMenuItems(node: LGraphNode): IContextMenuValue[] {
|
|
return useExtensionService()
|
|
.invokeExtensions('getNodeMenuItems', node)
|
|
.flat() as IContextMenuValue[]
|
|
}
|
|
|
|
/**
|
|
* Reload node definitions and refresh combo lists on all nodes.
|
|
*/
|
|
async reloadNodeDefs() {
|
|
const defs = await this.getNodeDefs()
|
|
for (const nodeId in defs) {
|
|
this.registerNodeDef(nodeId, defs[nodeId])
|
|
}
|
|
// Refresh combo widgets in all nodes including those in subgraphs
|
|
const nodeOutputStore = useNodeOutputStore()
|
|
forEachNode(this.rootGraph, (node) => {
|
|
const def = defs[node.type]
|
|
// Allow primitive nodes to handle refresh
|
|
node.refreshComboInNode?.(defs)
|
|
|
|
if (!def?.input) return
|
|
|
|
if (node.widgets) {
|
|
const nodeInputs = def.input
|
|
for (const widget of node.widgets) {
|
|
if (widget.type === 'combo') {
|
|
let inputType: 'required' | 'optional' | undefined
|
|
if (nodeInputs.required?.[widget.name] !== undefined) {
|
|
inputType = 'required'
|
|
} else if (nodeInputs.optional?.[widget.name] !== undefined) {
|
|
inputType = 'optional'
|
|
}
|
|
if (inputType !== undefined) {
|
|
// Get the input spec associated with the widget
|
|
const inputSpec = nodeInputs[inputType]?.[widget.name]
|
|
if (inputSpec) {
|
|
// Refresh the combo widget's options with the values from the input spec
|
|
if (isComboInputSpecV2(inputSpec)) {
|
|
widget.options.values = inputSpec[1]?.options
|
|
} else if (isComboInputSpecV1(inputSpec)) {
|
|
widget.options.values = inputSpec[0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-trigger previews on media nodes (e.g. LoadImage)
|
|
// to bust browser cache when files are edited externally
|
|
if (isImageNode(node) || isVideoNode(node)) {
|
|
nodeOutputStore.refreshNodeOutputs(node)
|
|
}
|
|
})
|
|
|
|
await useExtensionService().invokeExtensionsAsync(
|
|
'refreshComboInNodes',
|
|
defs
|
|
)
|
|
|
|
// Promoted widgets keep hosted option snapshots; sync them after source refresh hooks run.
|
|
syncPromotedComboHostOptions(this.rootGraph)
|
|
|
|
if (this.vueAppReady) {
|
|
this.updateVueAppNodeDefs(defs)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh combo list on whole nodes
|
|
*/
|
|
async refreshComboInNodes() {
|
|
const requestToastMessage: ToastMessageOptions = {
|
|
severity: 'info',
|
|
summary: t('g.update'),
|
|
detail: t('toastMessages.updateRequested')
|
|
}
|
|
if (this.vueAppReady) {
|
|
useToastStore().add(requestToastMessage)
|
|
}
|
|
|
|
try {
|
|
await this.reloadNodeDefs()
|
|
|
|
if (this.vueAppReady) {
|
|
useToastStore().add({
|
|
severity: 'success',
|
|
summary: t('g.updated'),
|
|
detail: t('toastMessages.nodeDefinitionsUpdated'),
|
|
life: 1000
|
|
})
|
|
}
|
|
} catch (error) {
|
|
if (this.vueAppReady) {
|
|
useToastStore().add({
|
|
severity: 'error',
|
|
summary: t('g.error'),
|
|
detail: t('toastMessages.nodeDefinitionsUpdateFailed')
|
|
})
|
|
}
|
|
throw error
|
|
} finally {
|
|
if (this.vueAppReady) {
|
|
useToastStore().remove(requestToastMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean current state
|
|
*/
|
|
clean() {
|
|
const nodeOutputStore = useNodeOutputStore()
|
|
nodeOutputStore.resetAllOutputsAndPreviews()
|
|
const executionErrorStore = useExecutionErrorStore()
|
|
executionErrorStore.clearAllErrors()
|
|
|
|
useDomWidgetStore().clear()
|
|
|
|
// Subgraph does not properly implement `clear` and the parent class's
|
|
// (`LGraph`) `clear` breaks the subgraph structure.
|
|
if (this.rootGraph && !this.canvas.subgraph) {
|
|
this.rootGraph.clear()
|
|
}
|
|
}
|
|
|
|
clientPosToCanvasPos(pos: Vector2): Vector2 {
|
|
if (!this.positionConversion) {
|
|
throw new Error('clientPosToCanvasPos called before setup')
|
|
}
|
|
return this.positionConversion.clientPosToCanvasPos(pos)
|
|
}
|
|
|
|
canvasPosToClientPos(pos: Vector2): Vector2 {
|
|
if (!this.positionConversion) {
|
|
throw new Error('canvasPosToClientPos called before setup')
|
|
}
|
|
return this.positionConversion.canvasPosToClientPos(pos)
|
|
}
|
|
}
|
|
|
|
export const app = new ComfyApp()
|