diff --git a/browser_tests/assets/missing/deprecated_nodes_complex.json b/browser_tests/assets/missing/deprecated_nodes_complex.json new file mode 100644 index 0000000000..30652414c5 --- /dev/null +++ b/browser_tests/assets/missing/deprecated_nodes_complex.json @@ -0,0 +1,205 @@ +{ + "last_node_id": 7, + "last_link_id": 5, + "nodes": [ + { + "id": 1, + "type": "T2IAdapterLoader", + "pos": [100, 100], + "size": [300, 80], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CONTROL_NET", + "type": "CONTROL_NET", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "T2IAdapterLoader" + }, + "widgets_values": ["t2iadapter_model.safetensors"] + }, + { + "id": 2, + "type": "CheckpointLoaderSimple", + "pos": [100, 300], + "size": [315, 98], + "flags": {}, + "order": 1, + "mode": 0, + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [], + "slot_index": 0 + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [], + "slot_index": 1 + }, + { + "name": "VAE", + "type": "VAE", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"] + }, + { + "id": 3, + "type": "ResizeImagesByLongerEdge", + "pos": [500, 100], + "size": [300, 80], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [1], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ResizeImagesByLongerEdge" + }, + "widgets_values": [1024] + }, + { + "id": 4, + "type": "ImageScaleBy", + "pos": [500, 280], + "size": [300, 80], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [2, 3], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ImageScaleBy" + }, + "widgets_values": ["lanczos", 1.5] + }, + { + "id": 5, + "type": "ImageBatch", + "pos": [900, 100], + "size": [300, 80], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "image1", + "type": "IMAGE", + "link": 2 + }, + { + "name": "image2", + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [4], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ImageBatch" + }, + "widgets_values": [] + }, + { + "id": 6, + "type": "SaveImage", + "pos": [900, 300], + "size": [300, 80], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 3 + } + ], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": ["ComfyUI"] + }, + { + "id": 7, + "type": "PreviewImage", + "pos": [1250, 100], + "size": [300, 250], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 4 + } + ], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + } + ], + "links": [ + [1, 3, 0, 4, 0, "IMAGE"], + [2, 4, 0, 5, 0, "IMAGE"], + [3, 4, 0, 6, 0, "IMAGE"], + [4, 5, 0, 7, 0, "IMAGE"] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [0, 0] + } + }, + "version": 0.4 +} diff --git a/browser_tests/assets/missing/deprecated_nodes_simple.json b/browser_tests/assets/missing/deprecated_nodes_simple.json new file mode 100644 index 0000000000..a12f4392ef --- /dev/null +++ b/browser_tests/assets/missing/deprecated_nodes_simple.json @@ -0,0 +1,186 @@ +{ + "last_node_id": 5, + "last_link_id": 2, + "nodes": [ + { + "id": 1, + "type": "Load3DAnimation", + "pos": [100, 100], + "size": [300, 100], + "flags": {}, + "order": 0, + "mode": 0, + "outputs": [ + { + "name": "MESH", + "type": "MESH", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "Load3DAnimation" + }, + "widgets_values": ["model.glb"] + }, + { + "id": 2, + "type": "Preview3DAnimation", + "pos": [450, 100], + "size": [300, 100], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "mesh", + "type": "MESH", + "link": null + } + ], + "properties": { + "Node name for S&R": "Preview3DAnimation" + }, + "widgets_values": [] + }, + { + "id": 3, + "type": "ConditioningAverage ", + "pos": [100, 300], + "size": [300, 100], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "conditioning_to", + "type": "CONDITIONING", + "link": null + }, + { + "name": "conditioning_from", + "type": "CONDITIONING", + "link": null + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [1], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ConditioningAverage " + }, + "widgets_values": [1] + }, + { + "id": 4, + "type": "SDV_img2vid_Conditioning", + "pos": [450, 300], + "size": [300, 150], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "clip_vision", + "type": "CLIP_VISION", + "link": null + }, + { + "name": "init_image", + "type": "IMAGE", + "link": null + }, + { + "name": "vae", + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "links": [], + "slot_index": 0 + }, + { + "name": "negative", + "type": "CONDITIONING", + "links": [], + "slot_index": 1 + }, + { + "name": "latent", + "type": "LATENT", + "links": [2], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "SDV_img2vid_Conditioning" + }, + "widgets_values": [1024, 576, 14, 127, 25, 0.02] + }, + { + "id": 5, + "type": "KSampler", + "pos": [800, 300], + "size": [300, 262], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": null + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 1 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": null + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 2 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1] + } + ], + "links": [ + [1, 3, 0, 5, 1, "CONDITIONING"], + [2, 4, 2, 5, 3, "LATENT"] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [0, 0] + } + }, + "version": 0.4 +} diff --git a/browser_tests/tests/nodeSearchBox.spec.ts-snapshots/added-node-chromium-linux.png b/browser_tests/tests/nodeSearchBox.spec.ts-snapshots/added-node-chromium-linux.png index 18ec479bfe..e221608dce 100644 Binary files a/browser_tests/tests/nodeSearchBox.spec.ts-snapshots/added-node-chromium-linux.png and b/browser_tests/tests/nodeSearchBox.spec.ts-snapshots/added-node-chromium-linux.png differ diff --git a/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png b/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png index 050c9d7086..3954a2d3ac 100644 Binary files a/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png and b/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png differ diff --git a/src/components/dialog/content/MissingNodesContent.vue b/src/components/dialog/content/MissingNodesContent.vue index 7c3a35b248..b6b75d827b 100644 --- a/src/components/dialog/content/MissingNodesContent.vue +++ b/src/components/dialog/content/MissingNodesContent.vue @@ -1,12 +1,12 @@ diff --git a/src/components/dialog/content/MissingNodesFooter.vue b/src/components/dialog/content/MissingNodesFooter.vue index 7c1740e6d4..65fd75807a 100644 --- a/src/components/dialog/content/MissingNodesFooter.vue +++ b/src/components/dialog/content/MissingNodesFooter.vue @@ -30,8 +30,18 @@ + +
+ +
+ -
+
- +
- () + const dialogStore = useDialogStore() const { t } = useI18n() @@ -109,6 +124,12 @@ function openShowMissingNodesSetting() { const { missingNodePacks, isLoading, error } = useMissingNodes() const comfyManagerStore = useComfyManagerStore() const managerState = useManagerState() +function handleOpenManager() { + managerState.openManager({ + initialTab: ManagerTab.Missing, + showToastOnLegacyError: true + }) +} // Check if any of the missing packs are currently being installed const isInstalling = computed(() => { @@ -128,15 +149,29 @@ const showInstallAllButton = computed(() => { return managerState.shouldShowInstallButton.value }) -const openManager = async () => { - await managerState.openManager({ - initialTab: ManagerTab.Missing, - showToastOnLegacyError: true - }) -} +const hasNonReplaceableNodes = computed( + () => + missingNodeTypes?.some( + (n) => + typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable) + ) ?? false +) -// Computed to check if all missing nodes have been installed +// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install) +const hadMissingPacks = ref(false) + +watch( + missingNodePacks, + (packs) => { + if (packs && packs.length > 0) hadMissingPacks.value = true + }, + { immediate: true } +) + +// Only consider "all installed" when packs transitioned from non-empty to empty +// (actual installation happened). Replaceable-only case is handled by Content auto-close. const allMissingNodesInstalled = computed(() => { + if (!hadMissingPacks.value) return false return ( !isLoading.value && !isInstalling.value && diff --git a/src/locales/en/main.json b/src/locales/en/main.json index de5ddc3f15..51240c3222 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2903,6 +2903,25 @@ "replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas." } }, + "nodeReplacement": { + "quickFixAvailable": "Quick Fix Available", + "installationRequired": "Installation Required", + "compatibleAlternatives": "Compatible Alternatives", + "replaceable": "Replaceable", + "replaced": "Replaced", + "notReplaceable": "Install Required", + "selectAll": "Select All", + "replaceSelected": "Replace Selected ({count})", + "replacedNode": "Replaced node: {nodeType}", + "replacedAllNodes": "Replaced {count} node type(s)", + "replaceFailed": "Failed to replace nodes", + "instructionMessage": "You must install these nodes or replace them with installed alternatives to run the workflow. Missing nodes are highlighted in {red} on the canvas. Some nodes cannot be swapped and must be installed via Node Manager.", + "redHighlight": "red", + "openNodeManager": "Open Node Manager", + "skipForNow": "Skip for Now", + "installMissingNodes": "Install Missing Nodes", + "replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure." + }, "rightSidePanel": { "togglePanel": "Toggle properties panel", "noSelection": "Select a node to see its properties and info.", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index f6addeb503..f4673cb160 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -285,8 +285,8 @@ "name": "Show API node pricing badge" }, "Comfy_NodeReplacement_Enabled": { - "name": "Enable automatic node replacement", - "tooltip": "When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists." + "name": "Enable node replacement suggestions", + "tooltip": "When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements." }, "Comfy_NodeSearchBoxImpl": { "name": "Node search box implementation", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index d1cc4e5adb..c75892bd6f 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -249,8 +249,8 @@ "name": "API 노드 가격 배지 표시" }, "Comfy_NodeReplacement_Enabled": { - "name": "자동 노드 교체 활성화", - "tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다." + "name": "노드 교체 제안 활성화", + "tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다." }, "Comfy_NodeSearchBoxImpl": { "name": "노드 검색 상자 구현", diff --git a/src/platform/nodeReplacement/useNodeReplacement.test.ts b/src/platform/nodeReplacement/useNodeReplacement.test.ts new file mode 100644 index 0000000000..72c0d80f93 --- /dev/null +++ b/src/platform/nodeReplacement/useNodeReplacement.test.ts @@ -0,0 +1,654 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import type { NodeReplacement } from './types' +import type { MissingNodeType } from '@/types/comfy' + +vi.mock('@/lib/litegraph/src/litegraph', () => ({ + LiteGraph: { + createNode: vi.fn(), + registered_node_types: {} + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { rootGraph: null }, + sanitizeNodeName: (name: string) => name.replace(/[&<>"'`=]/g, '') +})) + +vi.mock('@/utils/graphTraversalUtil', () => ({ + collectAllNodes: vi.fn() +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: vi.fn(() => ({ + add: vi.fn() + })) +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: vi.fn(() => ({ + activeWorkflow: { + changeTracker: { + beforeChange: vi.fn(), + afterChange: vi.fn() + } + } + })) +})) + +vi.mock('@/i18n', () => ({ + t: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key +})) + +import { app } from '@/scripts/app' +import { collectAllNodes } from '@/utils/graphTraversalUtil' +import { useNodeReplacement } from './useNodeReplacement' + +function createMockLink( + id: number, + originId: number, + originSlot: number, + targetId: number, + targetSlot: number +) { + return { + id, + origin_id: originId, + origin_slot: originSlot, + target_id: targetId, + target_slot: targetSlot, + type: 'IMAGE' + } +} + +function createMockGraph( + nodes: LGraphNode[], + links: ReturnType[] = [] +): LGraph { + const linksMap = new Map(links.map((l) => [l.id, l])) + return { + _nodes: nodes, + _nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])), + links: linksMap, + updateExecutionOrder: vi.fn(), + setDirtyCanvas: vi.fn() + } as unknown as LGraph +} + +function createPlaceholderNode( + id: number, + type: string, + inputs: { name: string; link: number | null }[] = [], + outputs: { name: string; links: number[] | null }[] = [], + graph?: LGraph +): LGraphNode { + return { + id, + type, + pos: [100, 200], + size: [200, 100], + order: 0, + mode: 0, + flags: {}, + has_errors: true, + last_serialization: { + id, + type, + pos: [100, 200], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })), + outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })), + widgets_values: [] + }, + inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })), + outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })), + graph: graph ?? null, + serialize: vi.fn(() => ({ + id, + type, + pos: [100, 200], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })), + outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })), + widgets_values: [] + })) + } as unknown as LGraphNode +} + +function createNewNode( + inputs: { name: string; link: number | null }[] = [], + outputs: { name: string; links: number[] | null }[] = [], + widgets: { name: string; value: unknown }[] = [] +): LGraphNode { + return { + id: 0, + type: '', + pos: [0, 0], + size: [100, 50], + order: 0, + mode: 0, + flags: {}, + has_errors: false, + inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })), + outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })), + widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })), + configure: vi.fn(), + serialize: vi.fn() + } as unknown as LGraphNode +} + +function makeMissingNodeType( + type: string, + replacement: NodeReplacement +): MissingNodeType { + return { + type, + isReplaceable: true, + replacement + } +} + +describe('useNodeReplacement', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + }) + + describe('replaceNodesInPlace', () => { + it('should return empty array when no placeholders exist', () => { + const graph = createMockGraph([]) + Object.assign(app, { rootGraph: graph }) + vi.mocked(collectAllNodes).mockReturnValue([]) + + const { replaceNodesInPlace } = useNodeReplacement() + const result = replaceNodesInPlace([]) + + expect(result).toEqual([]) + }) + + it('should use default mapping when no explicit mapping exists', () => { + const placeholder = createPlaceholderNode(1, 'Load3DAnimation') + const graph = createMockGraph([placeholder]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode() + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + const result = replaceNodesInPlace([ + makeMissingNodeType('Load3DAnimation', { + new_node_id: 'Load3D', + old_node_id: 'Load3DAnimation', + old_widget_ids: null, + input_mapping: null, + output_mapping: null + }) + ]) + + expect(result).toEqual(['Load3DAnimation']) + expect(newNode.configure).not.toHaveBeenCalled() + expect(newNode.id).toBe(1) + expect(newNode.has_errors).toBe(false) + }) + + it('should transfer input connections using input_mapping', () => { + const link = createMockLink(10, 5, 0, 1, 0) + const placeholder = createPlaceholderNode( + 1, + 'T2IAdapterLoader', + [{ name: 't2i_adapter_name', link: 10 }], + [] + ) + const graph = createMockGraph([placeholder], [link]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode( + [{ name: 'control_net_name', link: null }], + [] + ) + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + const result = replaceNodesInPlace([ + makeMissingNodeType('T2IAdapterLoader', { + new_node_id: 'ControlNetLoader', + old_node_id: 'T2IAdapterLoader', + old_widget_ids: null, + input_mapping: [ + { new_id: 'control_net_name', old_id: 't2i_adapter_name' } + ], + output_mapping: null + }) + ]) + + expect(result).toEqual(['T2IAdapterLoader']) + // Link should be updated to point at new node's input + expect(link.target_id).toBe(1) + expect(link.target_slot).toBe(0) + expect(newNode.inputs[0].link).toBe(10) + }) + + it('should transfer output connections using output_mapping', () => { + const link = createMockLink(20, 1, 0, 5, 0) + const placeholder = createPlaceholderNode( + 1, + 'ResizeImagesByLongerEdge', + [], + [{ name: 'IMAGE', links: [20] }] + ) + const graph = createMockGraph([placeholder], [link]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode( + [{ name: 'image', link: null }], + [{ name: 'IMAGE', links: null }] + ) + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + replaceNodesInPlace([ + makeMissingNodeType('ResizeImagesByLongerEdge', { + new_node_id: 'ImageScaleToMaxDimension', + old_node_id: 'ResizeImagesByLongerEdge', + old_widget_ids: ['longer_edge'], + input_mapping: [ + { new_id: 'image', old_id: 'images' }, + { new_id: 'largest_size', old_id: 'longer_edge' }, + { new_id: 'upscale_method', set_value: 'lanczos' } + ], + output_mapping: [{ new_idx: 0, old_idx: 0 }] + }) + ]) + + // Output link should be remapped + expect(link.origin_id).toBe(1) + expect(link.origin_slot).toBe(0) + expect(newNode.outputs[0].links).toEqual([20]) + }) + + it('should apply set_value to widget', () => { + const placeholder = createPlaceholderNode(1, 'ImageScaleBy') + const graph = createMockGraph([placeholder]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode( + [{ name: 'input', link: null }], + [], + [ + { name: 'resize_type', value: '' }, + { name: 'scale_method', value: '' } + ] + ) + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + replaceNodesInPlace([ + makeMissingNodeType('ImageScaleBy', { + new_node_id: 'ResizeImageMaskNode', + old_node_id: 'ImageScaleBy', + old_widget_ids: ['upscale_method', 'scale_by'], + input_mapping: [ + { new_id: 'input', old_id: 'image' }, + { new_id: 'resize_type', set_value: 'scale by multiplier' }, + { new_id: 'resize_type.multiplier', old_id: 'scale_by' }, + { new_id: 'scale_method', old_id: 'upscale_method' } + ], + output_mapping: null + }) + ]) + + // set_value should be applied to the widget + expect(newNode.widgets![0].value).toBe('scale by multiplier') + }) + + it('should transfer widget values using old_widget_ids', () => { + const placeholder = createPlaceholderNode(1, 'ResizeImagesByLongerEdge') + // Set widget values in serialized data + placeholder.last_serialization!.widgets_values = [512] + + const graph = createMockGraph([placeholder]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode( + [ + { name: 'image', link: null }, + { name: 'largest_size', link: null } + ], + [{ name: 'IMAGE', links: null }], + [{ name: 'largest_size', value: 0 }] + ) + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + replaceNodesInPlace([ + makeMissingNodeType('ResizeImagesByLongerEdge', { + new_node_id: 'ImageScaleToMaxDimension', + old_node_id: 'ResizeImagesByLongerEdge', + old_widget_ids: ['longer_edge'], + input_mapping: [ + { new_id: 'image', old_id: 'images' }, + { new_id: 'largest_size', old_id: 'longer_edge' }, + { new_id: 'upscale_method', set_value: 'lanczos' } + ], + output_mapping: [{ new_idx: 0, old_idx: 0 }] + }) + ]) + + // Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size" + expect(newNode.widgets![0].value).toBe(512) + }) + + it('should skip replacement when new node type is not registered', () => { + const placeholder = createPlaceholderNode(1, 'UnknownNode') + const graph = createMockGraph([placeholder]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + vi.mocked(LiteGraph.createNode).mockReturnValue(null) + + const { replaceNodesInPlace } = useNodeReplacement() + const result = replaceNodesInPlace([ + makeMissingNodeType('UnknownNode', { + new_node_id: 'NonExistentNode', + old_node_id: 'UnknownNode', + old_widget_ids: null, + input_mapping: null, + output_mapping: null + }) + ]) + + expect(result).toEqual([]) + }) + + it('should replace multiple different node types at once', () => { + const placeholder1 = createPlaceholderNode(1, 'Load3DAnimation') + const placeholder2 = createPlaceholderNode( + 2, + 'ConditioningAverage', + [], + [] + ) + // sanitizeNodeName strips & from type names (HTML entity chars) + placeholder2.type = 'ConditioningAverage' + + const graph = createMockGraph([placeholder1, placeholder2]) + placeholder1.graph = graph + placeholder2.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder1, placeholder2]) + + const newNode1 = createNewNode() + const newNode2 = createNewNode() + vi.mocked(LiteGraph.createNode) + .mockReturnValueOnce(newNode1) + .mockReturnValueOnce(newNode2) + + const { replaceNodesInPlace } = useNodeReplacement() + const result = replaceNodesInPlace([ + makeMissingNodeType('Load3DAnimation', { + new_node_id: 'Load3D', + old_node_id: 'Load3DAnimation', + old_widget_ids: null, + input_mapping: null, + output_mapping: null + }), + makeMissingNodeType('ConditioningAverage&', { + new_node_id: 'ConditioningAverage', + old_node_id: 'ConditioningAverage&', + old_widget_ids: null, + input_mapping: null, + output_mapping: null + }) + ]) + + expect(result).toHaveLength(2) + expect(result).toContain('Load3DAnimation') + expect(result).toContain('ConditioningAverage&') + }) + + it('should copy position and identity for mapped replacements', () => { + const link = createMockLink(10, 5, 0, 1, 0) + const placeholder = createPlaceholderNode( + 42, + 'T2IAdapterLoader', + [{ name: 't2i_adapter_name', link: 10 }], + [] + ) + placeholder.pos = [300, 400] + placeholder.size = [250, 150] + + const graph = createMockGraph([placeholder], [link]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode( + [{ name: 'control_net_name', link: null }], + [] + ) + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + replaceNodesInPlace([ + makeMissingNodeType('T2IAdapterLoader', { + new_node_id: 'ControlNetLoader', + old_node_id: 'T2IAdapterLoader', + old_widget_ids: null, + input_mapping: [ + { new_id: 'control_net_name', old_id: 't2i_adapter_name' } + ], + output_mapping: null + }) + ]) + + expect(newNode.id).toBe(42) + expect(newNode.pos).toEqual([300, 400]) + expect(newNode.size).toEqual([250, 150]) + expect(graph._nodes[0]).toBe(newNode) + }) + + it('should transfer all widget values for ImageScaleBy with real workflow data', () => { + const placeholder = createPlaceholderNode( + 12, + 'ImageScaleBy', + [{ name: 'image', link: 2 }], + [{ name: 'IMAGE', links: [3, 4] }] + ) + // Real workflow data: widgets_values: ["lanczos", 2.0] + placeholder.last_serialization!.widgets_values = ['lanczos', 2.0] + + const graph = createMockGraph([placeholder]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode( + [{ name: 'input', link: null }], + [], + [ + { name: 'resize_type', value: '' }, + { name: 'scale_method', value: '' } + ] + ) + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + replaceNodesInPlace([ + makeMissingNodeType('ImageScaleBy', { + new_node_id: 'ResizeImageMaskNode', + old_node_id: 'ImageScaleBy', + old_widget_ids: ['upscale_method', 'scale_by'], + input_mapping: [ + { new_id: 'input', old_id: 'image' }, + { new_id: 'resize_type', set_value: 'scale by multiplier' }, + { new_id: 'resize_type.multiplier', old_id: 'scale_by' }, + { new_id: 'scale_method', old_id: 'upscale_method' } + ], + output_mapping: null + }) + ]) + + // set_value should be applied + expect(newNode.widgets![0].value).toBe('scale by multiplier') + // upscale_method (idx 0, value "lanczos") → scale_method widget + expect(newNode.widgets![1].value).toBe('lanczos') + }) + + it('should transfer widget value for ResizeImagesByLongerEdge with real workflow data', () => { + const link = createMockLink(1, 5, 0, 8, 0) + const placeholder = createPlaceholderNode( + 8, + 'ResizeImagesByLongerEdge', + [{ name: 'images', link: 1 }], + [{ name: 'IMAGE', links: [2] }] + ) + // Real workflow data: widgets_values: [1024] + placeholder.last_serialization!.widgets_values = [1024] + + const graph = createMockGraph([placeholder], [link]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode( + [ + { name: 'image', link: null }, + { name: 'largest_size', link: null } + ], + [{ name: 'IMAGE', links: null }], + [ + { name: 'largest_size', value: 0 }, + { name: 'upscale_method', value: '' } + ] + ) + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + replaceNodesInPlace([ + makeMissingNodeType('ResizeImagesByLongerEdge', { + new_node_id: 'ImageScaleToMaxDimension', + old_node_id: 'ResizeImagesByLongerEdge', + old_widget_ids: ['longer_edge'], + input_mapping: [ + { new_id: 'image', old_id: 'images' }, + { new_id: 'largest_size', old_id: 'longer_edge' }, + { new_id: 'upscale_method', set_value: 'lanczos' } + ], + output_mapping: [{ new_idx: 0, old_idx: 0 }] + }) + ]) + + // longer_edge (idx 0, value 1024) → largest_size widget + expect(newNode.widgets![0].value).toBe(1024) + // set_value "lanczos" → upscale_method widget + expect(newNode.widgets![1].value).toBe('lanczos') + }) + + it('should transfer ConditioningAverage widget value with real workflow data', () => { + const link = createMockLink(4, 7, 0, 13, 0) + // sanitizeNodeName doesn't strip spaces, so placeholder keeps trailing space + const placeholder = createPlaceholderNode( + 13, + 'ConditioningAverage ', + [ + { name: 'conditioning_to', link: 4 }, + { name: 'conditioning_from', link: null } + ], + [{ name: 'CONDITIONING', links: [6] }] + ) + placeholder.last_serialization!.widgets_values = [0.75] + + const graph = createMockGraph([placeholder], [link]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode( + [ + { name: 'conditioning_to', link: null }, + { name: 'conditioning_from', link: null } + ], + [{ name: 'CONDITIONING', links: null }], + [{ name: 'conditioning_average', value: 0 }] + ) + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + replaceNodesInPlace([ + makeMissingNodeType('ConditioningAverage ', { + new_node_id: 'ConditioningAverage', + old_node_id: 'ConditioningAverage ', + old_widget_ids: null, + input_mapping: null, + output_mapping: null + }) + ]) + + // Default mapping transfers connections and widget values by name + expect(newNode.id).toBe(13) + expect(newNode.inputs[0].link).toBe(4) + expect(newNode.outputs[0].links).toEqual([6]) + expect(newNode.widgets![0].value).toBe(0.75) + }) + + it('should skip dot-notation input connections but still transfer widget values', () => { + const placeholder = createPlaceholderNode(1, 'ImageBatch') + const graph = createMockGraph([placeholder]) + placeholder.graph = graph + Object.assign(app, { rootGraph: graph }) + + vi.mocked(collectAllNodes).mockReturnValue([placeholder]) + + const newNode = createNewNode([], []) + vi.mocked(LiteGraph.createNode).mockReturnValue(newNode) + + const { replaceNodesInPlace } = useNodeReplacement() + const result = replaceNodesInPlace([ + makeMissingNodeType('ImageBatch', { + new_node_id: 'BatchImagesNode', + old_node_id: 'ImageBatch', + old_widget_ids: null, + input_mapping: [ + { new_id: 'images.image0', old_id: 'image1' }, + { new_id: 'images.image1', old_id: 'image2' } + ], + output_mapping: null + }) + ]) + + // Should still succeed (dot-notation skipped gracefully) + expect(result).toEqual(['ImageBatch']) + }) + }) +}) diff --git a/src/platform/nodeReplacement/useNodeReplacement.ts b/src/platform/nodeReplacement/useNodeReplacement.ts new file mode 100644 index 0000000000..5b39cb90dc --- /dev/null +++ b/src/platform/nodeReplacement/useNodeReplacement.ts @@ -0,0 +1,292 @@ +import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation' +import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets' +import { t } from '@/i18n' +import type { NodeReplacement } from '@/platform/nodeReplacement/types' +import { useToastStore } from '@/platform/updates/common/toastStore' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { app, sanitizeNodeName } from '@/scripts/app' +import type { MissingNodeType } from '@/types/comfy' +import { collectAllNodes } from '@/utils/graphTraversalUtil' + +/** Compares sanitized type strings to match placeholder → missing node type. */ +function findMatchingType( + node: LGraphNode, + selectedTypes: MissingNodeType[] +): Extract | undefined { + const nodeType = node.type + for (const selected of selectedTypes) { + if (typeof selected !== 'object' || !selected.isReplaceable) continue + if (sanitizeNodeName(selected.type) === nodeType) return selected + } + return undefined +} + +function transferInputConnection( + oldNode: LGraphNode, + oldInputName: string, + newNode: LGraphNode, + newInputName: string, + graph: LGraph +): void { + const oldSlotIdx = oldNode.inputs?.findIndex((i) => i.name === oldInputName) + const newSlotIdx = newNode.inputs?.findIndex((i) => i.name === newInputName) + if (oldSlotIdx == null || oldSlotIdx === -1) return + if (newSlotIdx == null || newSlotIdx === -1) return + + const linkId = oldNode.inputs[oldSlotIdx].link + if (linkId == null) return + + const link = graph.links.get(linkId) + if (!link) return + + link.target_id = newNode.id + link.target_slot = newSlotIdx + newNode.inputs[newSlotIdx].link = linkId + oldNode.inputs[oldSlotIdx].link = null +} + +function transferOutputConnections( + oldNode: LGraphNode, + oldOutputIdx: number, + newNode: LGraphNode, + newOutputIdx: number, + graph: LGraph +): void { + const oldLinks = oldNode.outputs?.[oldOutputIdx]?.links + if (!oldLinks?.length) return + if (!newNode.outputs?.[newOutputIdx]) return + + for (const linkId of oldLinks) { + const link = graph.links.get(linkId) + if (!link) continue + link.origin_id = newNode.id + link.origin_slot = newOutputIdx + } + newNode.outputs[newOutputIdx].links = [...oldLinks] + oldNode.outputs[oldOutputIdx].links = [] +} + +/** Uses old_widget_ids as name→index lookup into widgets_values. */ +function transferWidgetValue( + serialized: ISerialisedNode, + oldWidgetIds: string[] | null, + oldInputName: string, + newNode: LGraphNode, + newInputName: string +): void { + if (!oldWidgetIds || !serialized.widgets_values) return + + const oldWidgetIdx = oldWidgetIds.indexOf(oldInputName) + if (oldWidgetIdx === -1) return + + const oldValue = serialized.widgets_values[oldWidgetIdx] + if (oldValue === undefined) return + + const newWidget = newNode.widgets?.find((w) => w.name === newInputName) + if (newWidget) { + newWidget.value = oldValue + newWidget.callback?.(oldValue) + } +} + +function applySetValue( + newNode: LGraphNode, + inputName: string, + value: unknown +): void { + const widget = newNode.widgets?.find((w) => w.name === inputName) + if (widget) { + widget.value = value as TWidgetValue + widget.callback?.(widget.value) + } +} + +function isDotNotation(id: string): boolean { + return id.includes('.') +} + +/** Auto-generates identity mapping by name for same-structure replacements without backend mapping. */ +function generateDefaultMapping( + serialized: ISerialisedNode, + newNode: LGraphNode +): Pick< + NodeReplacement, + 'input_mapping' | 'output_mapping' | 'old_widget_ids' +> { + const oldInputNames = new Set(serialized.inputs?.map((i) => i.name) ?? []) + + const inputMapping: { old_id: string; new_id: string }[] = [] + for (const newInput of newNode.inputs ?? []) { + if (oldInputNames.has(newInput.name)) { + inputMapping.push({ old_id: newInput.name, new_id: newInput.name }) + } + } + + const oldWidgetIds = (newNode.widgets ?? []).map((w) => w.name) + for (const widget of newNode.widgets ?? []) { + if (!oldInputNames.has(widget.name)) { + inputMapping.push({ old_id: widget.name, new_id: widget.name }) + } + } + + const outputMapping: { old_idx: number; new_idx: number }[] = [] + for (const [oldIdx, oldOutput] of (serialized.outputs ?? []).entries()) { + const newIdx = newNode.outputs?.findIndex((o) => o.name === oldOutput.name) + if (newIdx != null && newIdx !== -1) { + outputMapping.push({ old_idx: oldIdx, new_idx: newIdx }) + } + } + + return { + input_mapping: inputMapping.length > 0 ? inputMapping : null, + output_mapping: outputMapping.length > 0 ? outputMapping : null, + old_widget_ids: oldWidgetIds.length > 0 ? oldWidgetIds : null + } +} + +function replaceWithMapping( + node: LGraphNode, + newNode: LGraphNode, + replacement: NodeReplacement, + nodeGraph: LGraph, + idx: number +): void { + newNode.id = node.id + newNode.pos = [...node.pos] + newNode.size = [...node.size] + newNode.order = node.order + newNode.mode = node.mode + if (node.flags) newNode.flags = { ...node.flags } + + nodeGraph._nodes[idx] = newNode + newNode.graph = nodeGraph + nodeGraph._nodes_by_id[newNode.id] = newNode + + const serialized = node.last_serialization ?? node.serialize() + + if (serialized.title != null) newNode.title = serialized.title + if (serialized.properties) { + newNode.properties = { ...serialized.properties } + if ('Node name for S&R' in newNode.properties) { + newNode.properties['Node name for S&R'] = replacement.new_node_id + } + } + + if (replacement.input_mapping) { + for (const inputMap of replacement.input_mapping) { + if ('old_id' in inputMap) { + if (isDotNotation(inputMap.new_id)) continue // Autogrow/DynamicCombo + transferInputConnection( + node, + inputMap.old_id, + newNode, + inputMap.new_id, + nodeGraph + ) + transferWidgetValue( + serialized, + replacement.old_widget_ids, + inputMap.old_id, + newNode, + inputMap.new_id + ) + } else { + if (!isDotNotation(inputMap.new_id)) { + applySetValue(newNode, inputMap.new_id, inputMap.set_value) + } + } + } + } + + if (replacement.output_mapping) { + for (const outMap of replacement.output_mapping) { + transferOutputConnections( + node, + outMap.old_idx, + newNode, + outMap.new_idx, + nodeGraph + ) + } + } + + newNode.has_errors = false +} + +export function useNodeReplacement() { + const toastStore = useToastStore() + + function replaceNodesInPlace(selectedTypes: MissingNodeType[]): string[] { + const replacedTypes: string[] = [] + const graph = app.rootGraph + + const changeTracker = + useWorkflowStore().activeWorkflow?.changeTracker ?? null + changeTracker?.beforeChange() + + try { + const placeholders = collectAllNodes( + graph, + (n) => !!n.has_errors && !!n.last_serialization + ) + + for (const node of placeholders) { + const match = findMatchingType(node, selectedTypes) + if (!match?.replacement) continue + + const replacement = match.replacement + const nodeGraph = node.graph + if (!nodeGraph) continue + + const idx = nodeGraph._nodes.indexOf(node) + if (idx === -1) continue + + const newNode = LiteGraph.createNode(replacement.new_node_id) + if (!newNode) continue + + const hasMapping = + replacement.input_mapping != null || + replacement.output_mapping != null + + const effectiveReplacement = hasMapping + ? replacement + : { + ...replacement, + ...generateDefaultMapping( + node.last_serialization ?? node.serialize(), + newNode + ) + } + replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx) + + if (!replacedTypes.includes(match.type)) { + replacedTypes.push(match.type) + } + } + + if (replacedTypes.length > 0) { + graph.updateExecutionOrder() + graph.setDirtyCanvas(true, true) + + toastStore.add({ + severity: 'success', + summary: t('g.success'), + detail: t('nodeReplacement.replacedAllNodes', { + count: replacedTypes.length + }), + life: 3000 + }) + } + } finally { + changeTracker?.afterChange() + } + + return replacedTypes + } + + return { + replaceNodesInPlace + } +} diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 9feb47655b..e809dbacf6 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -1202,9 +1202,9 @@ export const CORE_SETTINGS: SettingParams[] = [ { id: 'Comfy.NodeReplacement.Enabled', category: ['Comfy', 'Workflow', 'NodeReplacement'], - name: 'Enable automatic node replacement', + name: 'Enable node replacement suggestions', tooltip: - 'When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists.', + 'When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements.', type: 'boolean', defaultValue: false, experimental: true, diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 09eaeab636..5a14b3a644 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -113,7 +113,7 @@ import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste' export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview' -function sanitizeNodeName(string: string) { +export function sanitizeNodeName(string: string) { let entityMap = { '&': '', '<': '', @@ -1162,16 +1162,6 @@ export class ComfyApp { return } for (let n of nodes) { - // When node replacement is disabled, fall back to hardcoded patches - if (!nodeReplacementStore.isEnabled) { - if (n.type == 'T2IAdapterLoader') n.type = 'ControlNetLoader' - if (n.type == 'ConditioningAverage ') n.type = 'ConditioningAverage' - if (n.type == 'SDV_img2vid_Conditioning') - n.type = 'SVD_img2vid_Conditioning' - if (n.type == 'Load3DAnimation') n.type = 'Load3D' - if (n.type == 'Preview3DAnimation') n.type = 'Preview3D' - } - // Find missing node types if (!(n.type in LiteGraph.registered_node_types)) { const replacement = nodeReplacementStore.getReplacementFor(n.type) diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index 6e61121d9b..b24e048c20 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -109,7 +109,10 @@ export const useDialogService = () => { } } }, - props + props, + footerProps: { + missingNodeTypes: props.missingNodeTypes + } }) }