mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
[feat] Node replacement UI (#8604)
## Summary Add node replacement UI to the missing nodes dialog. Users can select and replace deprecated/missing nodes with compatible alternatives directly from the dialog. ## Changes - Classify missing nodes into **Replaceable** (quick fix) and **Install Required** sections - Add select-all checkbox + per-node checkboxes for batch replacement - `useNodeReplacement` composable handles in-place node replacement on the graph: - Simple replacement (configure+copy) for nodes without mapping - Input/output connection remapping for nodes with mapping - Widget value transfer via `old_widget_ids` - Dot-notation input handling for Autogrow/DynamicCombo - Undo/redo support via `changeTracker` (try/finally) - Title and properties preservation - Footer UX: "Skip for Now" button when all nodes are replaceable (cloud + OSS) - Auto-close dialog when all replaceable nodes are replaced and no non-replaceable remain - Settings navigation link from "Don't show again" checkbox - 505-line unit test suite for `useNodeReplacement` ## Review Focus - `useNodeReplacement.ts` — core graph manipulation logic - `MissingNodesContent.vue` — checkbox selection state management - `MissingNodesFooter.vue` — conditional button rendering (cloud vs OSS vs all-replaceable) [screen-capture.webm](https://github.com/user-attachments/assets/7dae891c-926c-4f26-987f-9637c4a2ca16) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8604-feat-Node-replacement-UI-2fd6d73d36508148a371dabb8f4115af) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
205
browser_tests/assets/missing/deprecated_nodes_complex.json
Normal file
205
browser_tests/assets/missing/deprecated_nodes_complex.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
186
browser_tests/assets/missing/deprecated_nodes_simple.json
Normal file
186
browser_tests/assets/missing/deprecated_nodes_simple.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex w-[490px] flex-col border-t-1 border-border-default"
|
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
|
||||||
:class="isCloud ? 'border-b-1' : ''"
|
:class="isCloud ? 'border-b' : ''"
|
||||||
>
|
>
|
||||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div>
|
<div>
|
||||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
<p class="m-0 text-sm leading-5 text-muted-foreground">
|
||||||
{{
|
{{
|
||||||
isCloud
|
isCloud
|
||||||
? $t('missingNodes.cloud.description')
|
? $t('missingNodes.cloud.description')
|
||||||
@@ -14,32 +14,210 @@
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
|
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
|
||||||
|
|
||||||
<!-- Missing Nodes List Wrapper -->
|
<!-- QUICK FIX AVAILABLE Section -->
|
||||||
<div
|
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
|
||||||
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
|
<!-- Section header with Replace button -->
|
||||||
>
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-semibold uppercase text-primary">
|
||||||
|
{{ $t('nodeReplacement.quickFixAvailable') }}
|
||||||
|
</span>
|
||||||
|
<div class="h-2 w-2 rounded-full bg-primary" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
:disabled="selectedTypes.size === 0"
|
||||||
|
@click="handleReplaceSelected"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
|
||||||
|
{{
|
||||||
|
$t('nodeReplacement.replaceSelected', {
|
||||||
|
count: selectedTypes.size
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Replaceable nodes list -->
|
||||||
<div
|
<div
|
||||||
v-for="(node, i) in uniqueNodes"
|
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||||
:key="i"
|
|
||||||
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
|
|
||||||
>
|
>
|
||||||
<span class="text-xs">
|
<!-- Select All row (sticky header) -->
|
||||||
{{ node.label }}
|
<div
|
||||||
</span>
|
:class="
|
||||||
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
|
cn(
|
||||||
|
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
|
||||||
|
pendingNodes.length > 0
|
||||||
|
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||||
|
: 'opacity-50 pointer-events-none'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
tabindex="0"
|
||||||
|
role="checkbox"
|
||||||
|
:aria-checked="
|
||||||
|
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
|
||||||
|
"
|
||||||
|
@click="toggleSelectAll"
|
||||||
|
@keydown.enter.prevent="toggleSelectAll"
|
||||||
|
@keydown.space.prevent="toggleSelectAll"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
|
||||||
|
:class="
|
||||||
|
isAllSelected || isSomeSelected
|
||||||
|
? 'bg-primary-background'
|
||||||
|
: 'bg-secondary-background'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-if="isAllSelected"
|
||||||
|
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
v-else-if="isSomeSelected"
|
||||||
|
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
{{ $t('nodeReplacement.compatibleAlternatives') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Replaceable node items -->
|
||||||
|
<div
|
||||||
|
v-for="node in replaceableNodes"
|
||||||
|
:key="node.label"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2',
|
||||||
|
replacedTypes.has(node.label)
|
||||||
|
? 'opacity-50 pointer-events-none'
|
||||||
|
: 'cursor-pointer hover:bg-secondary-background-hover'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
tabindex="0"
|
||||||
|
role="checkbox"
|
||||||
|
:aria-checked="
|
||||||
|
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||||
|
? 'true'
|
||||||
|
: 'false'
|
||||||
|
"
|
||||||
|
@click="toggleNode(node.label)"
|
||||||
|
@keydown.enter.prevent="toggleNode(node.label)"
|
||||||
|
@keydown.space.prevent="toggleNode(node.label)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
|
||||||
|
:class="
|
||||||
|
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||||
|
? 'bg-primary-background'
|
||||||
|
: 'bg-secondary-background'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-if="
|
||||||
|
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||||
|
"
|
||||||
|
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
v-if="replacedTypes.has(node.label)"
|
||||||
|
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
|
||||||
|
>
|
||||||
|
{{ $t('nodeReplacement.replaced') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
|
||||||
|
>
|
||||||
|
{{ $t('nodeReplacement.replaceable') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-foreground">
|
||||||
|
{{ node.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom instruction -->
|
<!-- MANUAL INSTALLATION REQUIRED Section -->
|
||||||
<div>
|
<div
|
||||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
v-if="nonReplaceableNodes.length > 0"
|
||||||
{{
|
class="flex max-h-[200px] flex-col gap-2"
|
||||||
isCloud
|
>
|
||||||
? $t('missingNodes.cloud.replacementInstruction')
|
<!-- Section header -->
|
||||||
: $t('missingNodes.oss.replacementInstruction')
|
<div class="flex items-center gap-2">
|
||||||
}}
|
<span class="text-xs font-semibold uppercase text-error">
|
||||||
|
{{ $t('nodeReplacement.installationRequired') }}
|
||||||
|
</span>
|
||||||
|
<i class="icon-[lucide--info] text-xs text-error" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Non-replaceable nodes list -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="node in nonReplaceableNodes"
|
||||||
|
:key="node.label"
|
||||||
|
class="flex items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
|
||||||
|
>
|
||||||
|
{{ $t('nodeReplacement.notReplaceable') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-foreground">
|
||||||
|
{{ node.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="node.hint" class="text-xs text-muted-foreground">
|
||||||
|
{{ node.hint }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="node.action"
|
||||||
|
variant="destructive-textonly"
|
||||||
|
size="sm"
|
||||||
|
@click="node.action.callback"
|
||||||
|
>
|
||||||
|
{{ node.action.text }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom instruction box -->
|
||||||
|
<div
|
||||||
|
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
|
||||||
|
/>
|
||||||
|
<p class="m-0 text-xs leading-5 text-neutral-foreground">
|
||||||
|
<i18n-t keypath="nodeReplacement.instructionMessage">
|
||||||
|
<template #red>
|
||||||
|
<span class="text-error">{{
|
||||||
|
$t('nodeReplacement.redHighlight')
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,23 +225,39 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||||
|
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import type { MissingNodeType } from '@/types/comfy'
|
import type { MissingNodeType } from '@/types/comfy'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const { missingNodeTypes } = defineProps<{
|
||||||
missingNodeTypes: MissingNodeType[]
|
missingNodeTypes: MissingNodeType[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Get missing core nodes for OSS mode
|
|
||||||
const { missingCoreNodes } = useMissingNodes()
|
const { missingCoreNodes } = useMissingNodes()
|
||||||
|
const { replaceNodesInPlace } = useNodeReplacement()
|
||||||
|
const dialogStore = useDialogStore()
|
||||||
|
|
||||||
const uniqueNodes = computed(() => {
|
interface ProcessedNode {
|
||||||
const seenTypes = new Set()
|
label: string
|
||||||
return props.missingNodeTypes
|
hint?: string
|
||||||
|
action?: { text: string; callback: () => void }
|
||||||
|
isReplaceable: boolean
|
||||||
|
replacement?: NodeReplacement
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacedTypes = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const uniqueNodes = computed<ProcessedNode[]>(() => {
|
||||||
|
const seenTypes = new Set<string>()
|
||||||
|
return missingNodeTypes
|
||||||
.filter((node) => {
|
.filter((node) => {
|
||||||
const type = typeof node === 'object' ? node.type : node
|
const type = typeof node === 'object' ? node.type : node
|
||||||
if (seenTypes.has(type)) return false
|
if (seenTypes.has(type)) return false
|
||||||
@@ -75,10 +269,81 @@ const uniqueNodes = computed(() => {
|
|||||||
return {
|
return {
|
||||||
label: node.type,
|
label: node.type,
|
||||||
hint: node.hint,
|
hint: node.hint,
|
||||||
action: node.action
|
action: node.action,
|
||||||
|
isReplaceable: node.isReplaceable ?? false,
|
||||||
|
replacement: node.replacement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { label: node }
|
return { label: node, isReplaceable: false }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const replaceableNodes = computed(() =>
|
||||||
|
uniqueNodes.value.filter((n) => n.isReplaceable)
|
||||||
|
)
|
||||||
|
|
||||||
|
const pendingNodes = computed(() =>
|
||||||
|
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
|
||||||
|
)
|
||||||
|
|
||||||
|
const nonReplaceableNodes = computed(() =>
|
||||||
|
uniqueNodes.value.filter((n) => !n.isReplaceable)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selection state - all pending nodes selected by default
|
||||||
|
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
|
||||||
|
|
||||||
|
const isAllSelected = computed(
|
||||||
|
() =>
|
||||||
|
pendingNodes.value.length > 0 &&
|
||||||
|
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
|
||||||
|
)
|
||||||
|
|
||||||
|
const isSomeSelected = computed(
|
||||||
|
() => selectedTypes.value.size > 0 && !isAllSelected.value
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleNode(label: string) {
|
||||||
|
if (replacedTypes.value.has(label)) return
|
||||||
|
const next = new Set(selectedTypes.value)
|
||||||
|
if (next.has(label)) {
|
||||||
|
next.delete(label)
|
||||||
|
} else {
|
||||||
|
next.add(label)
|
||||||
|
}
|
||||||
|
selectedTypes.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (isAllSelected.value) {
|
||||||
|
selectedTypes.value = new Set()
|
||||||
|
} else {
|
||||||
|
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReplaceSelected() {
|
||||||
|
const selected = missingNodeTypes.filter((node) => {
|
||||||
|
const type = typeof node === 'object' ? node.type : node
|
||||||
|
return selectedTypes.value.has(type)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = replaceNodesInPlace(selected)
|
||||||
|
const nextReplaced = new Set(replacedTypes.value)
|
||||||
|
const nextSelected = new Set(selectedTypes.value)
|
||||||
|
for (const type of result) {
|
||||||
|
nextReplaced.add(type)
|
||||||
|
nextSelected.delete(type)
|
||||||
|
}
|
||||||
|
replacedTypes.value = nextReplaced
|
||||||
|
selectedTypes.value = nextSelected
|
||||||
|
|
||||||
|
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
|
||||||
|
const allReplaced = replaceableNodes.value.every((n) =>
|
||||||
|
nextReplaced.has(n.label)
|
||||||
|
)
|
||||||
|
if (allReplaced && nonReplaceableNodes.value.length === 0) {
|
||||||
|
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -30,8 +30,18 @@
|
|||||||
</i18n-t>
|
</i18n-t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
|
||||||
|
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
|
||||||
|
<Button variant="secondary" size="md" @click="handleGotItClick">
|
||||||
|
{{ $t('nodeReplacement.skipForNow') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||||
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
|
<div
|
||||||
|
v-else-if="isCloud"
|
||||||
|
class="flex w-full items-center justify-between gap-2"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="textonly"
|
variant="textonly"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -48,9 +58,9 @@
|
|||||||
}}</Button>
|
}}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
<!-- OSS mode: Manager buttons -->
|
||||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
|
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
|
||||||
<Button variant="textonly" @click="openManager">{{
|
<Button variant="textonly" @click="handleOpenManager">{{
|
||||||
$t('g.openManager')
|
$t('g.openManager')
|
||||||
}}</Button>
|
}}</Button>
|
||||||
<PackInstallButton
|
<PackInstallButton
|
||||||
@@ -82,12 +92,17 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
|||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
import type { MissingNodeType } from '@/types/comfy'
|
||||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||||
|
|
||||||
|
const { missingNodeTypes } = defineProps<{
|
||||||
|
missingNodeTypes?: MissingNodeType[]
|
||||||
|
}>()
|
||||||
|
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -109,6 +124,12 @@ function openShowMissingNodesSetting() {
|
|||||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||||
const comfyManagerStore = useComfyManagerStore()
|
const comfyManagerStore = useComfyManagerStore()
|
||||||
const managerState = useManagerState()
|
const managerState = useManagerState()
|
||||||
|
function handleOpenManager() {
|
||||||
|
managerState.openManager({
|
||||||
|
initialTab: ManagerTab.Missing,
|
||||||
|
showToastOnLegacyError: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Check if any of the missing packs are currently being installed
|
// Check if any of the missing packs are currently being installed
|
||||||
const isInstalling = computed(() => {
|
const isInstalling = computed(() => {
|
||||||
@@ -128,15 +149,29 @@ const showInstallAllButton = computed(() => {
|
|||||||
return managerState.shouldShowInstallButton.value
|
return managerState.shouldShowInstallButton.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const openManager = async () => {
|
const hasNonReplaceableNodes = computed(
|
||||||
await managerState.openManager({
|
() =>
|
||||||
initialTab: ManagerTab.Missing,
|
missingNodeTypes?.some(
|
||||||
showToastOnLegacyError: true
|
(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(() => {
|
const allMissingNodesInstalled = computed(() => {
|
||||||
|
if (!hadMissingPacks.value) return false
|
||||||
return (
|
return (
|
||||||
!isLoading.value &&
|
!isLoading.value &&
|
||||||
!isInstalling.value &&
|
!isInstalling.value &&
|
||||||
|
|||||||
@@ -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."
|
"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": {
|
"rightSidePanel": {
|
||||||
"togglePanel": "Toggle properties panel",
|
"togglePanel": "Toggle properties panel",
|
||||||
"noSelection": "Select a node to see its properties and info.",
|
"noSelection": "Select a node to see its properties and info.",
|
||||||
|
|||||||
@@ -285,8 +285,8 @@
|
|||||||
"name": "Show API node pricing badge"
|
"name": "Show API node pricing badge"
|
||||||
},
|
},
|
||||||
"Comfy_NodeReplacement_Enabled": {
|
"Comfy_NodeReplacement_Enabled": {
|
||||||
"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."
|
"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": {
|
"Comfy_NodeSearchBoxImpl": {
|
||||||
"name": "Node search box implementation",
|
"name": "Node search box implementation",
|
||||||
|
|||||||
@@ -249,8 +249,8 @@
|
|||||||
"name": "API 노드 가격 배지 표시"
|
"name": "API 노드 가격 배지 표시"
|
||||||
},
|
},
|
||||||
"Comfy_NodeReplacement_Enabled": {
|
"Comfy_NodeReplacement_Enabled": {
|
||||||
"name": "자동 노드 교체 활성화",
|
"name": "노드 교체 제안 활성화",
|
||||||
"tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다."
|
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
|
||||||
},
|
},
|
||||||
"Comfy_NodeSearchBoxImpl": {
|
"Comfy_NodeSearchBoxImpl": {
|
||||||
"name": "노드 검색 상자 구현",
|
"name": "노드 검색 상자 구현",
|
||||||
|
|||||||
654
src/platform/nodeReplacement/useNodeReplacement.test.ts
Normal file
654
src/platform/nodeReplacement/useNodeReplacement.test.ts
Normal file
@@ -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<string, unknown>) =>
|
||||||
|
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<typeof createMockLink>[] = []
|
||||||
|
): 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
292
src/platform/nodeReplacement/useNodeReplacement.ts
Normal file
292
src/platform/nodeReplacement/useNodeReplacement.ts
Normal file
@@ -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<MissingNodeType, { type: string }> | 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1202,9 +1202,9 @@ export const CORE_SETTINGS: SettingParams[] = [
|
|||||||
{
|
{
|
||||||
id: 'Comfy.NodeReplacement.Enabled',
|
id: 'Comfy.NodeReplacement.Enabled',
|
||||||
category: ['Comfy', 'Workflow', 'NodeReplacement'],
|
category: ['Comfy', 'Workflow', 'NodeReplacement'],
|
||||||
name: 'Enable automatic node replacement',
|
name: 'Enable node replacement suggestions',
|
||||||
tooltip:
|
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',
|
type: 'boolean',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
experimental: true,
|
experimental: true,
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
|
|||||||
|
|
||||||
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
|
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
|
||||||
|
|
||||||
function sanitizeNodeName(string: string) {
|
export function sanitizeNodeName(string: string) {
|
||||||
let entityMap = {
|
let entityMap = {
|
||||||
'&': '',
|
'&': '',
|
||||||
'<': '',
|
'<': '',
|
||||||
@@ -1162,16 +1162,6 @@ export class ComfyApp {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (let n of nodes) {
|
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
|
// Find missing node types
|
||||||
if (!(n.type in LiteGraph.registered_node_types)) {
|
if (!(n.type in LiteGraph.registered_node_types)) {
|
||||||
const replacement = nodeReplacementStore.getReplacementFor(n.type)
|
const replacement = nodeReplacementStore.getReplacementFor(n.type)
|
||||||
|
|||||||
@@ -109,7 +109,10 @@ export const useDialogService = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
props
|
props,
|
||||||
|
footerProps: {
|
||||||
|
missingNodeTypes: props.missingNodeTypes
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user