Add validation evidence: v4: Make Fix node (recreate) work inside a subgraph
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
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."
|
||||
}
|
||||
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 136 KiB |