Add validation evidence: v4: Make Fix node (recreate) work inside a subgraph

This commit is contained in:
Dr.Lt.Data
2026-11-14 06:08:12 +09:00
commit b34ba7fb4c
12 changed files with 312 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Validation evidence: v4: Make Fix node (recreate) work inside a subgraph
Implementation: `b3821788851e2a6b6860af14a5246a4a6802e4a4` on `fix/v4-node-fixer-subgraph`, targeting `manager-v4`.
- `v4-node-fixer-subgraph-pr-assets/annotated/`: the captures with boxes and captions laid over them
- `v4-node-fixer-subgraph-pr-assets/screens/`: the unmodified browser captures
- `v4-node-fixer-subgraph-pr-assets/evidence.json`: what was captured, from which `node_fixer.js`, with hashes
- `v4-node-fixer-subgraph-pr-assets/capture_evidence.cjs`: the script that made the captures
- `manifest.json`: checksums of the files here
+45
View File
@@ -0,0 +1,45 @@
{
"implementation_head": "b3821788851e2a6b6860af14a5246a4a6802e4a4",
"files": {
"v4-node-fixer-subgraph-pr-assets/annotated/green-2-clicked.png": {
"bytes": 134471,
"sha256": "30199c30952423f2a9d65e1bd31621accd7f99e414fdc4acd92e944d94a56f12"
},
"v4-node-fixer-subgraph-pr-assets/annotated/green-3-moved.png": {
"bytes": 164212,
"sha256": "280fd339662d8a37bb551ebecc919f375ff7f92c81f303d43f014c3067127c01"
},
"v4-node-fixer-subgraph-pr-assets/annotated/red-2-clicked.png": {
"bytes": 146569,
"sha256": "6a2d34d37702415572dbc6679cb8710d927bc5a43ca13abf9c3810d6a1c3bf55"
},
"v4-node-fixer-subgraph-pr-assets/annotated/red-3-moved.png": {
"bytes": 157268,
"sha256": "13f6f3b52400034acaaed85fdeaba3c3a70900797a083c45b2feb3be0b805325"
},
"v4-node-fixer-subgraph-pr-assets/capture_evidence.cjs": {
"bytes": 7235,
"sha256": "c9be6ac78ee998bca5b06c12b36770f392a28e52c0479810968305e134f8e67f"
},
"v4-node-fixer-subgraph-pr-assets/evidence.json": {
"bytes": 3756,
"sha256": "7d2f472e04abdd22311234c959e946d2af3d8c4420288af9470095d92fdb62bc"
},
"v4-node-fixer-subgraph-pr-assets/screens/green-2-clicked.png": {
"bytes": 118049,
"sha256": "f24aa696d2d27c133be99c6a0ba67d9ffcf7d13267afa19f5704d560a1fbb477"
},
"v4-node-fixer-subgraph-pr-assets/screens/green-3-moved.png": {
"bytes": 141041,
"sha256": "cbe88bf7e6c2442fa8ccf444570bce07bf32daa7b724fda2c5c2152977bcb2c4"
},
"v4-node-fixer-subgraph-pr-assets/screens/red-2-clicked.png": {
"bytes": 132727,
"sha256": "60ab5c5cbb2e46baa2c2a80629928f94f3cab28b7a8433c57109bfdc8660e05d"
},
"v4-node-fixer-subgraph-pr-assets/screens/red-3-moved.png": {
"bytes": 139490,
"sha256": "b1a79c7376d76850c7ed8121e71ffa1e4922176ce5f3207ca7565342ea7c091a"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

@@ -0,0 +1,139 @@
// Captures the Red/Green screens for "Fix node (recreate)" inside a subgraph.
// Same steps on both sides; only the served node_fixer.js differs.
//
// usage: node capture_evidence.cjs <playwright-core dir> <base url> <label> <out dir>
// label: "red" or "green" (names the output files only)
//
// Steps:
// 1. build CheckpointLoaderSimple + CLIPTextEncode -> KSampler -> VAEDecode on an empty canvas
// 2. convert the KSampler to a subgraph and open it; its links now go through
// the subgraph's input and output nodes
// 3. capture -> <label>-1-before.png
// 4. right-click the KSampler title, click "Fix node (recreate)"
// 5. capture -> <label>-2-clicked.png
// 6. click an empty spot of the canvas (closes a context menu left open)
// 7. drag whatever node is at the KSampler title 290px down
// 8. capture -> <label>-3-moved.png
// Step 7 is there because the replacement is created exactly on top of the
// original, so a leftover original cannot be seen without moving the top node.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const [pwDir, baseURL, label, outDir] = process.argv.slice(2);
const { chromium } = require(pwDir);
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
const DRAG_DY = 290;
// Where the KSampler is put, so that the subgraph's input and output nodes are on screen too.
const AT = [800, 330];
(async () => {
fs.mkdirSync(outDir, { recursive: true });
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'en-US' })).newPage();
const pageErrors = [];
page.on('pageerror', (e) => pageErrors.push(e.message));
await page.goto(baseURL, { waitUntil: 'load' });
await page.waitForFunction(() => !!window.LiteGraph?.registered_node_types?.KSampler, { timeout: 60_000 });
await page.waitForTimeout(4000);
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
const served = await page.evaluate(async () => {
const text = await (await fetch('/extensions/comfyui-manager-legacy/node_fixer.js')).text();
return { text, line: text.split('\n').find((l) => l.includes('links[input.link]')).trim() };
});
await page.evaluate(() => {
const { app, LiteGraph } = window;
const graph = app.canvas.graph;
graph.clear();
const add = (type, x, y) => { const n = LiteGraph.createNode(type); n.pos = [x, y]; graph.add(n); return n; };
const loader = add('CheckpointLoaderSimple', 50, 150);
const prompt = add('CLIPTextEncode', 50, 350);
const sampler = add('KSampler', 500, 150);
const decoder = add('VAEDecode', 900, 150);
loader.connect(0, sampler, 'model');
prompt.connect(0, sampler, 'positive');
sampler.connect(0, decoder, 'samples');
const converted = graph.convertToSubgraph(new Set([sampler]));
app.canvas.openSubgraph(converted.subgraph, converted.node);
});
await page.waitForTimeout(1500);
const original = await page.evaluate((AT) => {
const { app, LiteGraph } = window;
const sampler = app.canvas.graph.nodes.find((n) => n.type === 'KSampler');
app.canvas.ds.scale = 1;
app.canvas.ds.offset = [AT[0] - sampler.pos[0], AT[1] - sampler.pos[1]];
app.canvas.draw(true, true);
return { id: sampler.id, idType: typeof sampler.id, x: AT[0] + 60, y: AT[1] - LiteGraph.NODE_TITLE_HEIGHT / 2 };
}, AT);
await page.waitForTimeout(500);
const readNodes = () => page.evaluate((originalId) => {
const { app, LiteGraph } = window;
const graph = app.canvas.graph;
const [ox, oy] = app.canvas.ds.offset;
const origin = ({ origin_id, origin_slot }) => origin_id === graph.inputNode?.id
? `subgraph input.${graph.inputNode.slots[origin_slot].name}` : `${graph.getNodeById(origin_id).type}`;
const target = ({ target_id, target_slot }) => target_id === graph.outputNode?.id
? `subgraph output.${graph.outputNode.slots[target_slot].name}` : `${graph.getNodeById(target_id).type}`;
const rect = (r) => (r ? [r[0] + ox, r[1] + oy, r[2], r[3]].map(Math.round) : null);
return {
inSubgraph: graph !== app.graph,
boundary: { input: rect(graph.inputNode?.boundingRect), output: rect(graph.outputNode?.boundingRect) },
nodes: graph.nodes.map((n) => ({
id: n.id, type: n.type, isOriginal: n.id === originalId,
inputsFrom: Object.fromEntries(n.inputs.filter((i) => i.link != null).map((i) => [i.name, origin(graph.links.get(i.link))])),
outputsTo: (n.outputs?.[0]?.links ?? []).map((id) => target(graph.links.get(id))),
box: [n.pos[0] + ox, n.pos[1] + oy - LiteGraph.NODE_TITLE_HEIGHT, n.size[0], n.size[1] + LiteGraph.NODE_TITLE_HEIGHT].map(Math.round),
})),
};
}, original.id);
const shot = async (name) => {
const file = path.join(outDir, name);
await page.screenshot({ path: file });
return { file: name, sha256: sha256(fs.readFileSync(file)) };
};
const before = { ...await readNodes(), capture: await shot(`${label}-1-before.png`) };
await page.mouse.click(original.x, original.y, { button: 'right' });
await page.locator('.litecontextmenu .litemenu-entry', { hasText: 'Fix node (recreate)' }).click();
await page.waitForTimeout(500);
const menu = page.locator('.litecontextmenu').first();
const menuBox = await menu.count() ? await menu.boundingBox() : null;
const clicked = {
contextMenuOpen: !!menuBox,
menuBox: menuBox ? [menuBox.x, menuBox.y, menuBox.width, menuBox.height].map(Math.round) : null,
...await readNodes(),
capture: await shot(`${label}-2-clicked.png`),
};
await page.mouse.click(300, 760);
await page.waitForTimeout(300);
await page.mouse.move(original.x, original.y);
await page.mouse.down();
await page.mouse.move(original.x, original.y + DRAG_DY, { steps: 15 });
await page.mouse.up();
await page.mouse.move(40, 860);
await page.waitForTimeout(700);
const after = { ...await readNodes(), capture: await shot(`${label}-3-moved.png`) };
const result = {
label, capturedAt: new Date().toISOString(), baseURL,
frontend: await page.evaluate(() => window.__COMFYUI_FRONTEND_VERSION__ ?? null),
viewport: [1440, 900],
nodeFixer: { sha256: sha256(Buffer.from(served.text, 'utf-8')), linkLookupLine: served.line },
originalSampler: { id: original.id, idType: original.idType },
dragDy: DRAG_DY, pageErrors, before, clicked, after,
};
fs.writeFileSync(path.join(outDir, `${label}.json`), JSON.stringify(result, null, 2) + '\n');
await browser.close();
console.log(JSON.stringify({
label, frontend: result.frontend, line: served.line, pageErrors, inSubgraph: after.inSubgraph,
menuOpenAfterClick: clicked.contextMenuOpen, menuBox: clicked.menuBox, boundary: after.boundary,
after: after.nodes.map((n) => `${n.type}#${n.id}${n.isOriginal ? '(original)' : ''} in=${JSON.stringify(n.inputsFrom)} out=${JSON.stringify(n.outputsTo)} box=${n.box}`),
}, null, 1));
})().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,119 @@
{
"scenario": "Fix node (recreate) on a KSampler that was converted to a subgraph, so its links go through the subgraph's input and output nodes",
"steps": "see the header of capture_evidence.cjs",
"red": {
"frontend": "1.57.6",
"nodeIdType": "string",
"capturedAt": "2026-11-13T20:09:45.986Z",
"nodeFixer": {
"sha256": "d857b9ae4a6a7b171f52d309fbffcc9f60c68eb1a806b3e9bf2be7ebc6fdc305",
"linkLookupLine": "let link = app.graph.links[input.link];"
},
"pageErrors": [
"Cannot read properties of undefined (reading 'origin_id')"
],
"insideSubgraph": true,
"contextMenuOpenAfterClick": true,
"kSamplersAtEnd": [
{
"id": "3",
"isOriginal": true,
"inputsFrom": {
"model": "subgraph input.model",
"positive": "subgraph input.positive",
"seed": "subgraph input.seed"
},
"outputsTo": [
"subgraph output.LATENT"
]
},
{
"id": "6",
"isOriginal": false,
"inputsFrom": {},
"outputsTo": []
}
]
},
"green": {
"frontend": "1.57.6",
"nodeIdType": "string",
"capturedAt": "2026-11-13T20:09:55.602Z",
"nodeFixer": {
"sha256": "343077773836bc84ee1f13815a4edd3d49189af00ff6614836a9178c132efdc8",
"linkLookupLine": "let link = graph.links[input.link];"
},
"pageErrors": [],
"insideSubgraph": true,
"contextMenuOpenAfterClick": false,
"kSamplersAtEnd": [
{
"id": "6",
"isOriginal": false,
"inputsFrom": {
"model": "subgraph input.model",
"positive": "subgraph input.positive",
"seed": "subgraph input.seed"
},
"outputsTo": [
"subgraph output.LATENT"
]
}
]
},
"figures": [
{
"figure": "red-2-clicked",
"side": "red",
"step": "Right after clicking \"Fix node (recreate)\"",
"raw": {
"file": "screens/red-2-clicked.png",
"sha256": "60ab5c5cbb2e46baa2c2a80629928f94f3cab28b7a8433c57109bfdc8660e05d"
},
"annotated": {
"file": "annotated/red-2-clicked.png",
"sha256": "6a2d34d37702415572dbc6679cb8710d927bc5a43ca13abf9c3810d6a1c3bf55"
}
},
{
"figure": "green-2-clicked",
"side": "green",
"step": "Right after clicking \"Fix node (recreate)\"",
"raw": {
"file": "screens/green-2-clicked.png",
"sha256": "f24aa696d2d27c133be99c6a0ba67d9ffcf7d13267afa19f5704d560a1fbb477"
},
"annotated": {
"file": "annotated/green-2-clicked.png",
"sha256": "30199c30952423f2a9d65e1bd31621accd7f99e414fdc4acd92e944d94a56f12"
}
},
{
"figure": "red-3-moved",
"side": "red",
"step": "After dragging the node at the KSampler title 290px down",
"raw": {
"file": "screens/red-3-moved.png",
"sha256": "b1a79c7376d76850c7ed8121e71ffa1e4922176ce5f3207ca7565342ea7c091a"
},
"annotated": {
"file": "annotated/red-3-moved.png",
"sha256": "13f6f3b52400034acaaed85fdeaba3c3a70900797a083c45b2feb3be0b805325"
}
},
{
"figure": "green-3-moved",
"side": "green",
"step": "After dragging the node at the KSampler title 290px down",
"raw": {
"file": "screens/green-3-moved.png",
"sha256": "cbe88bf7e6c2442fa8ccf444570bce07bf32daa7b724fda2c5c2152977bcb2c4"
},
"annotated": {
"file": "annotated/green-3-moved.png",
"sha256": "280fd339662d8a37bb551ebecc919f375ff7f92c81f303d43f014c3067127c01"
}
}
],
"note": "annotated/*.png = screens/*.png with an HTML overlay rendered on top; screens/*.png are the unmodified captures."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB