Compare commits

..
4 Commits
13 changed files with 258 additions and 83 deletions
+4
View File
@@ -32,3 +32,7 @@ jobs:
for source in pathlib.Path('web').glob('*.js'):
subprocess.run(['node', '--input-type=module', '--check'], input=source.read_bytes(), check=True)
PY
- name: Check exclusive routing behavior
run: node --experimental-default-type=module tests/test_exclusive_routes.mjs
- name: Check comparison result history
run: node --experimental-default-type=module tests/test_compare_history.mjs
+25
View File
@@ -1,5 +1,30 @@
# Changelog
## 0.5.9
- Bound comparison bitmap resolution to source detail, 8192 pixels per edge and a 16 Mi-pixel allocation budget, avoiding quadratic bitmap growth at large workflow zoom.
- Redraw only the strip swept by the comparison divider during pointer movement; redraw fully after image, size or hover changes.
- Draw the divider as a separate overlay so it remains one screen pixel wide even when magnifying the workflow.
## 0.5.8
- Count each comparison preview file once, even when cached execution notifications replay it; keep the selected result on replay.
- Remove duplicate preview entries when reloading older saved comparison histories.
## 0.5.7
- Accumulate comparison results across runs, select the newest result and preserve each result's matching original, including after workflow reload.
- Keep comparison images permanently fitted and centered in the canvas; remove independent wheel zoom and middle/Space image panning.
- Route wheel events over the full comparison node, including the picture, to ComfyUI workflow navigation.
- Preserve hover comparison, the full-result view on pointer leave, batch selection and adoption with mask/history retention.
## 0.5.6
- Follow KJNodes Get/Set routes and reroutes when deciding which exclusive input is enabled, including the setter's and real source node's bypass/mute states.
- Support virtual routers using the current resolveVirtualOutput API, and safely reject routing cycles or missing setters.
- Exclude inactive inputs from detached execution DTOs before frontend link resolution, avoiding missing-input errors when a virtual Get itself is bypassed; retain all editable/saved workflow wires.
- Add dependency-free routing regression checks to CI and validate actual KJ Get/Set workflows with real CPU queue output pixels in both supported frontend versions/renderers.
## 0.5.5
- Bind the inline canvas, inpaint parameter panel and comparison widget width directly to the node's logical graph width. Cached screen-space measurements cannot shrink the widgets a second time at reduced graph zoom.
+5 -2
View File
@@ -104,11 +104,13 @@ Parameters driven by an external link disable the matching panel control; a modu
## Comparison and chained inpainting
Add an **Image Compare** node and connect Inline Mask's `image` to `original` and the final full-size generated `image` to `result`, then run.
With the pointer over the picture, the thin divider shows the generated result on the left and the original on the right; leaving the picture hides the divider and shows the full result, and the plain wheel zooms. Batch selection, dimensions and the fit button sit below the picture.
With the pointer over the picture, the thin divider shows the generated result on the left and the original on the right; leaving the picture hides the divider and shows the full result, and the image always remains fitted and centered in its canvas. Scrolling over the comparison image navigates the ComfyUI workflow background using the current navigation settings; comparison images do not zoom or pan independently. Batch selection, dimensions and the fit button sit below the picture.
When you are happy, click **Adopt this result**: the plugin uploads the selected result as the new source image and writes it back into the upstream inline editor while preserving the painted mask, current undo/redo history and viewport. Use **Clear** when you want to start a new mask; a normal **Load image** upload still starts a fresh mask.
To preserve mask coordinates, an adopted result must match the source dimensions; a mismatched result is rejected without changing the current source or mask. The mask remains embedded in saved workflow data; command-level undo history is retained during adoption in the current session.
If the original is routed through intermediate IMAGE nodes the plugin walks upstream to find the inline editor; when several candidates exist you can pick one explicitly under **Adopt into**.
Adoption only happens when you click the button and never starts the next generation automatically; the `image` output still passes the whole result batch, so you can feed a Save Image node.
The result list accumulates images across runs and selects the newest result automatically. Each result keeps its original from that run, including when adopting results for further inpainting. Saving the workflow also saves this history's preview references and selection.
Comparison previews live in ComfyUI's temp directory and may need a re-run after cleanup or restart; an adopted source image is stored in `input/adachi-inpaint/` and does not depend on temp previews.
## Group bypass presets
@@ -153,7 +155,8 @@ Both enabled, or neither connected/enabled, produces an error instead of silentl
The node accepts conditioning, strings, images, masks, models, latents and other ComfyUI data types; both alternatives and the downstream socket should use the same data type.
The model-free `examples/04-exclusive-switch.json` demonstrates two branches with two group presets.
It has no switch widget. A small internal value is computed from the graph when queuing, including when a bypassed image/model transform would otherwise pass its input through.
Place the selector outside the two controlled groups and connect each group's final node to it; any routing nodes between them should remain enabled.
Place the selector outside the two controlled groups and connect each group's final node to it.
KJNodes Get/Set routes and reroutes are supported: an ignored/muted Get, its corresponding Set, or the source behind those routing nodes disables that selector branch. Missing setters and virtual routing cycles also count as unavailable. Ordinary processing nodes remain independent active sources; the selector does not guess the meaning of arbitrary processing chains.
The ignored branch is not requested by this selector; any separate preview/save outputs on that branch may still execute independently.
For raw API prompts, omit the ignored input and omit the internal `active_inputs` field; exactly one of `input_a` / `input_b` must remain.
+8 -4
View File
@@ -1,10 +1,14 @@
# Git versions and rollback
The current local version is **v0.5.5**. Both the installed plugin and the source repository retain local commits and tags. No GitHub push is part of this update.
The current local version is **v0.5.9**. Both the installed plugin and the source repository retain local commits and tags. This update is also pushed to the GitHub main branch.
| Tag | Contents |
|---|---|
| `v0.5.5` | Bind canvas/panel widths to node geometry and retain vertical resizing |
| `v0.5.9` | Bound comparison bitmap allocations and redraw only the swept divider strip |
| `v0.5.8` / `local-before-v0.5.9` | Deduplicate replayed comparison previews and repair duplicate saved history |
| `v0.5.7` / `local-before-v0.5.8` | Fixed comparison images; wheel navigates the workflow; retain result history across runs |
| `v0.5.6` / `local-before-v0.5.7` | Exclusive selector follows Get/Set and virtual routes correctly |
| `v0.5.5` / `local-before-v0.5.6` | Bind canvas/panel widths to node geometry and retain vertical resizing |
| `v0.5.4` / `local-before-v0.5.5` | Isolate Fit clicks and refresh editor assets on upgrade |
| `v0.5.3` / `local-before-v0.5.4` | Fit beside Load image in the inline mask, plus the 5D VAE fix |
| `v0.5.2` / `local-before-v0.5.3` | Single-frame 5D VAE support for image models, plus all previous features |
@@ -17,13 +21,13 @@ To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach local-before-v0.5.5
git switch --detach local-before-v0.5.9
```
Return to this version with:
```powershell
git switch --detach v0.5.5
git switch --detach v0.5.9
```
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
+9
View File
@@ -43,6 +43,15 @@ Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.
- 0.5.5: injecting a cached widget width equal to 35% of its node width reproduces the narrow-column shape on frontend 1.52.7 classic nodes. Before the fix, a 380px inline node had a 113px editor; afterwards its editor is 360px wide, with the remaining 20px reserved for native margins. Inpaint and comparison widgets recover their full widths as well.
- 0.5.5: browser checks on frontend 1.42.11 and 1.52.7, classic nodes and Nodes 2.0, cover attempted stale width writes, resizing node widths to 800/450/1050px, independent height expansion, graph zoom at 0.3/0.6/1.15, Fit with mask preservation, and saved workflow reload. The simulated failure is covered; the remote server itself was not accessed.
- 0.5.6: inspected the supplied workflow read-only: selectors 532/533 are fed by KJ Get nodes p/p_0 and n/n_0, while the p_0/n_0 setters are bypassed. The previous direct-node check therefore incorrectly classified both branches as enabled.
- 0.5.6: actual local KJNodes Set/Get frontend definitions were loaded only into isolated QA browsers (no KJ code is bundled). Across frontend 1.42.11/1.52.7 and classic/Nodes 2.0, 32 real CPU queue cases verify ignored setters, swapped states, ignored/muted real sources, ignored Get, explicit both/neither errors, and switching after workflow reload. Output image pixels match the selected branch; generating prompts does not change editable connections.
- 0.5.6: dependency-free routing tests cover Get/Set, reroutes, current virtual-output APIs, mute/bypass, missing setters/inputs and cycles. All 29 backend CPU tests continue to pass.
- 0.5.7: actual browser wheel events over a real queued comparison image change workflow navigation while the comparison transform stays fitted and centered. Verified on frontends 1.42.11 and 1.52.7 in classic nodes and Nodes 2.0, with left-result/right-original pixels, hover/leave behavior, batch selection, adoption preserving mask/history, workflow reload, rejected mismatched adoption and ordinary-upload mask reset.
- 0.5.7: three real queued two-image batches accumulate 2, 4 and 6 results and select the newest result. After adoption, old results retain their old original and new results use the adopted original; all six results survive workflow reload in both frontend versions/renderers. Dependency-free tests additionally cover legacy batch mapping, empty outputs and immutable appending.
- 0.5.8: real single-image queues on frontend 1.52.7 in classic nodes and Nodes 2.0 show 1/1 then 2/2. Replaying the same execution output twice does not increase the count or reset a manually selected result. Reloading a workflow containing duplicate preview references removes duplicates. Unit checks distinguish filenames, subfolders and file types.
- 0.5.9: on frontend 1.52.7 in classic nodes and Nodes 2.0, a 1024x1536 comparison was measured at workflow zoom 1x, 2x and 4x. At 4x, 24 synchronous paints with readback dropped from 483 to 36 ms (classic) and 453 to 35 ms (Nodes 2.0). These are isolated browser measurements, not end-to-end workflow FPS guarantees. The divider measured one screen pixel at every zoom. Dirty-strip redraws matched full-redraw pixels exactly across forward/reverse/unchanged divider movements and edge positions; pointer exit hides the divider.
The sampling path was also checked for noise-schedule continuity against the real `common_ksampler`/`KSampler` functions in the local ComfyUI; an actual checkpoint has not been loaded to test GPU image quality or every model family, so CPU/interface tests must not be treated as model-quality verification.
References used during implementation:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "adachi-inpaint-workbench"
version = "0.5.5"
version = "0.5.9"
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
+23
View File
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { appendComparison } from "../web/compare_history.js";
const first = {originals: ["source-a"], results: ["result-a"]};
const second = appendComparison(first, {originals: ["source-b"], results: ["result-b", "result-c"]});
assert.deepEqual(second, {originals: ["source-a", "source-b", "source-b"], results: ["result-a", "result-b", "result-c"]});
assert.deepEqual(first, {originals: ["source-a"], results: ["result-a"]}, "Do not mutate saved history");
const legacy = {originals: ["a", "b"], results: [1, 2, 3, 4]};
assert.deepEqual(appendComparison(legacy, {originals: ["c"], results: [5]}), {
originals: ["a", "a", "b", "b", "c"], results: [1, 2, 3, 4, 5],
});
assert.deepEqual(appendComparison(undefined, first), first);
assert.deepEqual(appendComparison(first, {}), first);
assert.deepEqual(appendComparison({}, {results: ["orphan"]}), {originals: [], results: []});
assert.deepEqual(appendComparison(JSON.parse(JSON.stringify(second)), first).results, ["result-a", "result-b", "result-c"], "Cached execution replay must not count twice");
const preview = {filename: "preview_001.png", subfolder: "", type: "temp"};
const one = {originals: ["source-a"], results: [preview]};
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview}]}).results.length, 1);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, filename: "preview_002.png"}]}).results.length, 2);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, subfolder: "other"}]}).results.length, 2);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, type: "output"}]}).results.length, 2);
assert.deepEqual(appendComparison({originals: ["source-a", "source-a"], results: [preview, {...preview}]}), one, "Repair duplicates already saved in workflows");
console.log("Comparison history checks passed");
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { branchIsEnabled } from "../web/exclusive_routes.js";
const nodes = new Map(), links = new Map();
const graph = {getNodeById: id => nodes.get(id), getLink: id => links.get(id)};
let sequence = 0;
function node(type, options = {}) {
const value = {id: ++sequence, type, mode: 0, inputs: [], graph, ...options};
nodes.set(value.id, value); return value;
}
function connect(source, target, name = "value") {
const id = ++sequence;
links.set(id, {origin_id: source.id, origin_slot: 0});
target.inputs.push({name, link: id});
}
const a = node("CLIPTextEncode"), b = node("CLIPTextEncode");
const sa = node("SetNode", {isVirtualNode: true}), sb = node("SetNode", {isVirtualNode: true});
connect(a, sa); connect(b, sb);
const ga = node("GetNode", {isVirtualNode: true, findSetter: () => sa});
const gb = node("GetNode", {isVirtualNode: true, findSetter: () => sb});
const switchNode = node("AdachiExclusiveSwitch");
connect(ga, switchNode, "input_a"); connect(gb, switchNode, "input_b");
const state = () => Number(branchIsEnabled(switchNode, "input_a")) | (Number(branchIsEnabled(switchNode, "input_b")) << 1);
assert.equal(state(), 3, "both real branches enabled");
sa.mode = 4; assert.equal(state(), 2, "ignored Set disables A despite enabled Get");
sa.mode = 0; sb.mode = 4; assert.equal(state(), 1, "reverse ignored Set selects A");
sb.mode = 0; a.mode = 4; assert.equal(state(), 2, "ignored encoder is found beyond Get/Set");
a.mode = 0; b.mode = 2; assert.equal(state(), 1, "muted encoder is also disabled");
b.mode = 0; ga.mode = 4; assert.equal(state(), 2, "ignored Get is disabled"); ga.mode = 0;
const reroute = node("Reroute"); connect(a, reroute); sa.inputs = []; connect(reroute, sa);
reroute.mode = 4; assert.equal(state(), 2, "ignored reroute behind Set is disabled");
reroute.mode = 0; a.mode = 4; assert.equal(state(), 2, "enabled reroute follows ignored source"); a.mode = 0;
sa.inputs = []; connect(ga, sa); assert.equal(state(), 2, "virtual routing cycle is disabled safely");
ga.findSetter = () => null; assert.equal(state(), 2, "missing setter is disabled");
const modern = node("VirtualRouter", {isVirtualNode: true, resolveVirtualOutput: () => ({node: a, slot: 0})});
const routed = node("AdachiExclusiveSwitch"); connect(modern, routed, "input_a");
assert.equal(branchIsEnabled(routed, "input_a"), true, "modern virtual output API is followed");
a.mode = 4;
assert.equal(branchIsEnabled(routed, "input_a"), false, "modern virtual output respects ignored source");
assert.equal(branchIsEnabled({...switchNode, graph: null}, "input_a"), false);
assert.equal(branchIsEnabled(switchNode, "not_connected"), false);
console.log("Exclusive routing: Get/Set, bypass/mute, reroutes, cycles and missing inputs passed");
+47 -51
View File
@@ -1,5 +1,4 @@
/* Original hover-comparison UI, MIT licensed. */
import { registerImageWheel } from "./wheel_gestures.js";
import { T } from "./i18n.js";
const element = (tag, className, text) => {
const node = document.createElement(tag);
@@ -19,7 +18,7 @@ export class ImageCompareView {
this.options = options;
this.root = element("div", "dd-inpaint dd-comparer");
this.originals = []; this.results = []; this.index = 0;
this.zoom = 1; this.pan = { x: 0, y: 0 }; this.split = .5;
this.split = .5;
this.version = 0; this.ready = false;
this.dimensionsLabel = element("span", "dd-dimensions");
const toolbar = element("div", "dd-row dd-compare-toolbar");
@@ -29,14 +28,17 @@ export class ImageCompareView {
toolbar.append(this.resultSelect, this.dimensionsLabel);
const spacer = element("span", "dd-spacer"); toolbar.append(spacer);
const fit = element("button", "", T("Fit")); fit.type = "button";
fit.onclick = () => { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }; toolbar.append(fit);
fit.onclick = event => { event.preventDefault(); event.stopPropagation(); this.schedule(); }; toolbar.append(fit);
this.stage = element("div", "dd-stage dd-compare-stage");
this.canvas = document.createElement("canvas"); this.canvas.tabIndex = 0;
this.canvas.setAttribute("aria-label", T("Result left, original right; hover to compare"));
this.canvas.oncontextmenu = event => event.preventDefault();
this.canvas.addEventListener("pointerenter", event => { this.hovered = true; this.move(event); this.schedule(); });
this.canvas.addEventListener("pointerleave", () => { this.hovered = false; this.schedule(); });
this.stage.append(this.canvas);
this.divider = element("div");
Object.assign(this.divider.style, { position: "absolute", background: "#ffffffb3", pointerEvents: "none" });
this.divider.hidden = true;
this.stage.append(this.canvas, this.divider);
this.empty = element("div", "dd-empty", T("Connect an original image and a result, then run the workflow")); this.stage.append(this.empty);
const adoption = element("div", "dd-row dd-adoption");
adoption.append(element("span", "dd-compare-label", T("Adopt into")));
@@ -46,34 +48,22 @@ export class ImageCompareView {
adoption.append(this.targetSelect);
this.adoptButton = element("button", "dd-primary", T("Adopt this result")); this.adoptButton.type = "button";
this.adoptButton.onclick = () => this.adopt(); adoption.append(this.adoptButton);
this.message = element("div", "dd-status", T("Move to compare · wheel to zoom · middle-drag to pan"));
this.message = element("div", "dd-status", T("Result left, original right; hover to compare"));
this.message.hidden = true; this.message.setAttribute("role", "alert");
this.root.append(this.stage, toolbar, adoption, this.message);
this.canvas.addEventListener("pointermove", event => this.move(event));
this.canvas.addEventListener("pointerdown", event => {
if (!this.ready) return;
if (!this.ready || event.button !== 0) return;
event.preventDefault(); event.stopPropagation(); this.canvas.focus({ preventScroll: true });
if (event.button === 1 || this.space) {
this.drag = { id: event.pointerId, x: event.clientX, y: event.clientY, pan: { ...this.pan } };
this.canvas.setPointerCapture(event.pointerId);
} else this.move(event);
this.move(event);
});
const finish = event => {
if (!this.drag || this.drag.id !== event.pointerId) return;
const id = this.drag.id; this.drag = null;
if (this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id);
};
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) this.canvas.addEventListener(name, finish);
this.stopWheelCapture = registerImageWheel(this.canvas, () => this.ready, event => this.wheel(event), options.onGraphWheel);
this.canvas.addEventListener("wheel", event => this.wheel(event), { passive: false });
this.canvas.addEventListener("keydown", event => {
if (event.code === "Space") { event.preventDefault(); event.stopPropagation(); this.space = true; }
else if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault(); event.stopPropagation();
this.split = Math.max(0, Math.min(1, this.split + (event.key === "ArrowRight" ? .02 : -.02))); this.schedule();
}
});
this.canvas.addEventListener("keyup", event => { if (event.code === "Space") this.space = false; });
this.canvas.addEventListener("blur", () => { this.space = false; });
this.observer = new ResizeObserver(() => this.schedule()); this.observer.observe(this.stage);
this.refreshTargets(); this.updateButton();
}
@@ -140,36 +130,21 @@ export class ImageCompareView {
transform() {
const rect = this.canvas.getBoundingClientRect();
if (this.viewport?.width && this.viewport?.height && rect.width && rect.height) {
this.pan.x *= rect.width / this.viewport.width;
this.pan.y *= rect.height / this.viewport.height;
}
this.viewport = { width: rect.width, height: rect.height };
const scale = this.before ? Math.min(rect.width / this.before.width, rect.height / this.before.height) * this.zoom : 1;
return { width: rect.width, height: rect.height, scale, x: (rect.width - (this.before?.width || 0) * scale) / 2 + this.pan.x, y: (rect.height - (this.before?.height || 0) * scale) / 2 + this.pan.y };
const scale = this.before ? Math.min(rect.width / this.before.width, rect.height / this.before.height) : 1;
return { width: rect.width, height: rect.height, scale, x: (rect.width - (this.before?.width || 0) * scale) / 2, y: (rect.height - (this.before?.height || 0) * scale) / 2 };
}
position(event) { const rect = this.canvas.getBoundingClientRect(), t = this.transform(); return { x: (event.clientX - rect.left - t.x) / t.scale, y: (event.clientY - rect.top - t.y) / t.scale }; }
wheel(event) {
if (!this.ready) return;
event.preventDefault(); event.stopPropagation();
const before = this.position(event), rect = this.canvas.getBoundingClientRect();
this.zoom = Math.max(.1, Math.min(32, this.zoom * Math.exp(-event.deltaY * .0015)));
const t = this.transform();
this.pan.x += event.clientX - rect.left - (t.x + before.x * t.scale);
this.pan.y += event.clientY - rect.top - (t.y + before.y * t.scale);
this.schedule();
this.options.onGraphWheel?.(event);
}
move(event) {
if (!this.ready) return;
this.hovered = true;
if (this.drag) {
event.preventDefault(); event.stopPropagation();
this.pan.x = this.drag.pan.x + event.clientX - this.drag.x;
this.pan.y = this.drag.pan.y + event.clientY - this.drag.y;
} else this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
this.schedule();
}
@@ -178,26 +153,47 @@ export class ImageCompareView {
render() {
const t = this.transform(), ratio = Math.min(devicePixelRatio || 1, 2);
if (!t.width || !t.height) return;
const width = Math.round(t.width * ratio), height = Math.round(t.height * ratio);
// Graph zoom changes the screen rect, not the amount of source detail.
// Never allocate a huge upscaled bitmap just to magnify existing pixels.
const rasterRatio = Math.min(ratio, this.ready ? 1 / t.scale : ratio,
8192 / t.width, 8192 / t.height, Math.sqrt(16 * 1024 * 1024 / (t.width * t.height)));
const width = Math.max(1, Math.round(t.width * rasterRatio)), height = Math.max(1, Math.round(t.height * rasterRatio));
if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; }
const ctx = this.canvas.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, width, height);
if (!this.ready) return;
ctx.setTransform(t.scale * ratio, 0, 0, t.scale * ratio, t.x * ratio, t.y * ratio);
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
if (!this.hovered) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
this.divider.hidden = !this.ready || !this.hovered;
if (!this.ready) { ctx.clearRect(0, 0, width, height); this.lastPaint = null; return; }
const split = this.split * this.before.width;
const lineX = Math.round((t.x + split * t.scale) * rasterRatio) + .5;
if (this.hovered) {
const screenScale = t.width / this.canvas.clientWidth || 1;
Object.assign(this.divider.style, {
left: `${(t.x + split * t.scale) / screenScale}px`, top: `${t.y / screenScale}px`,
height: `${this.before.height * t.scale / screenScale}px`, width: `${1 / screenScale}px`,
});
}
const key = [this.version, width, height, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio].join(":");
const previous = this.lastPaint;
ctx.save();
// While hovering, pixels outside the old/new divider positions are
// unchanged. Clip the redraw to that strip rather than repainting all.
if (previous?.key === key && previous.hovered && this.hovered) {
const left = Math.max(0, Math.floor(Math.min(previous.lineX, lineX)) - 2);
const right = Math.min(width, Math.ceil(Math.max(previous.lineX, lineX)) + 2);
ctx.beginPath(); ctx.rect(left, 0, right - left, height); ctx.clip();
}
ctx.clearRect(0, 0, width, height);
ctx.setTransform(t.scale * rasterRatio, 0, 0, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio);
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
this.lastPaint = { key, hovered: this.hovered, lineX };
if (!this.hovered) { ctx.restore(); return; }
ctx.save(); ctx.beginPath(); ctx.rect(split, 0, this.before.width - split, this.before.height); ctx.clip();
ctx.drawImage(this.before, 0, 0); ctx.restore();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const lineX = Math.round((t.x + split * t.scale) * ratio) + .5;
ctx.beginPath(); ctx.moveTo(lineX, t.y * ratio); ctx.lineTo(lineX, (t.y + this.before.height * t.scale) * ratio);
ctx.strokeStyle = "#ffffffb3"; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
}
destroy() {
this.destroyed = true; ++this.version; cancelAnimationFrame(this.frame); this.observer.disconnect();
this.stopWheelCapture();
this.before = this.after = null; this.canvas.width = this.canvas.height = 0; this.root.remove();
}
}
+26
View File
@@ -0,0 +1,26 @@
// Store one matching original per result, including batches from older workflows.
export function appendComparison(previous, incoming) {
const pairs = data => {
const originals = Array.isArray(data?.originals) ? data.originals : [];
const results = Array.isArray(data?.results) ? data.results : [];
if (!originals.length || !results.length) return { originals: [], results: [] };
const repetitions = results.length / originals.length;
return {
originals: results.map((_, index) => originals[Math.min(originals.length - 1, Math.floor(index / repetitions))]),
results,
};
};
const before = pairs(previous), next = pairs(incoming);
const history = { originals: [], results: [] }, seen = new Set();
for (const data of [before, next]) data.results.forEach((result, index) => {
// Cached execution notifications can replay the same preview. Its file
// identity is stable, while a genuinely new generation gets a new file.
const key = typeof result === "object" && result !== null
? JSON.stringify([result.type || "temp", result.subfolder || "", result.filename])
: JSON.stringify(result);
if (seen.has(key)) return;
seen.add(key);
history.originals.push(data.originals[index]); history.results.push(result);
});
return history;
}
+16 -5
View File
@@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ImageCompareView } from "./compare.js";
import { ImageCompareView } from "./compare.js?v=0.5.9";
import { appendComparison } from "./compare_history.js?v=0.5.8";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
@@ -51,7 +52,7 @@ app.registerExtension({
},
});
node.adachiCompare = compare;
const stopWheelRouting = routeWheelOutsideImage(compare.root, compare.canvas, () => compare.ready);
const stopWheelRouting = routeWheelOutsideImage(compare.root, null, () => false);
const widget = node.addDOMWidget("adachi_compare", "adachi_compare_view", compare.root, {
serialize: false, hideOnZoom: false, getMinHeight: () => 420, getMaxHeight: () => 4096,
});
@@ -60,15 +61,25 @@ app.registerExtension({
const executed = node.onExecuted;
node.onExecuted = function (output) {
const result = executed?.apply(this, arguments);
const data = { originals: output?.adachi_originals || [], results: output?.adachi_results || [] };
if (!output?.adachi_originals?.length || !output?.adachi_results?.length) return result;
const previous = appendComparison(node.properties.adachi_compare);
const data = appendComparison(previous, {
originals: output.adachi_originals, results: output.adachi_results,
});
node.properties.adachi_compare = data;
compare.setData(data, node.properties.adachi_result_index || 0);
node.properties.adachi_result_index = data.results.length > previous.results.length
? data.results.length - 1 : Math.max(0, Math.min(data.results.length - 1, node.properties.adachi_result_index || 0));
compare.setData(data, node.properties.adachi_result_index);
return result;
};
const configured = node.onConfigure;
node.onConfigure = function () {
const result = configured?.apply(this, arguments);
if (node.properties.adachi_compare) compare.setData(node.properties.adachi_compare, node.properties.adachi_result_index || 0);
if (node.properties.adachi_compare) {
node.properties.adachi_compare = appendComparison(node.properties.adachi_compare);
node.properties.adachi_result_index = Math.max(0, Math.min(node.properties.adachi_compare.results.length - 1, node.properties.adachi_result_index || 0));
compare.setData(node.properties.adachi_compare, node.properties.adachi_result_index);
}
return result;
};
const removed = node.onRemoved;
+35
View File
@@ -0,0 +1,35 @@
const disabled = node => node.mode === 2 || node.mode === 4;
const inputLink = (graph, input) => input?.link == null ? null
: graph.getLink?.(input.link) ?? graph.links?.[input.link];
/** Follow routing nodes without treating ordinary processing nodes as routers. */
export function branchIsEnabled(node, name) {
const graph = node.graph;
if (!graph) return false;
const seen = new Set();
function visit(source, slot = 0) {
if (!source || seen.has(source) || disabled(source)) return false;
seen.add(source);
const sourceGraph = source.graph || graph;
const follow = link => link ? visit(sourceGraph.getNodeById(link.origin_id), link.origin_slot) : false;
// KJ Get nodes have no physical inputs. Follow their actual setter so
// its ignored state is checked too; getInputLink alone skips the Set.
if (source.isVirtualNode && typeof source.findSetter === "function") {
return visit(source.findSetter(sourceGraph), slot);
}
if (source.type === "Reroute" || source.type === "SetNode") {
return follow(inputLink(sourceGraph, source.inputs?.[slot] || source.inputs?.[0]));
}
if (source.isVirtualNode && typeof source.resolveVirtualOutput === "function") {
const resolved = source.resolveVirtualOutput(slot);
if (resolved?.node) return visit(resolved.node, resolved.slot ?? 0);
}
if (source.isVirtualNode && typeof source.getInputLink === "function") {
return follow(source.getInputLink(slot));
}
return true;
}
const link = inputLink(graph, node.inputs?.find(input => input.name === name));
return link ? visit(graph.getNodeById(link.origin_id), link.origin_slot) : false;
}
+17 -20
View File
@@ -1,22 +1,5 @@
import { app } from "../../scripts/app.js";
function branchIsEnabled(node, name) {
const graph = node.graph;
let input = node.inputs?.find(input => input.name === name);
const visited = new Set();
while (graph && input?.link != null) {
const link = graph.getLink?.(input.link) ?? graph.links?.[input.link];
const source = link && graph.getNodeById(link.origin_id);
if (!source || visited.has(source.id)) return false;
visited.add(source.id);
// A bypassed transform normally passes its own input through in ComfyUI.
// For this exclusive selector the original branch is explicitly disabled.
if (source.mode === 2 || source.mode === 4) return false;
if (source.type !== "Reroute") return true;
input = source.inputs?.[0];
}
return false;
}
import { branchIsEnabled } from "./exclusive_routes.js?v=0.5.6";
app.registerExtension({
name: "AdachiWorkbench.ExclusiveSwitch",
@@ -32,8 +15,22 @@ app.registerExtension({
widget.options ??= {};
widget.options.hidden = true;
widget.computeSize = () => [0, -4];
widget.serializeValue = () => Number(branchIsEnabled(this, "input_a"))
| (Number(branchIsEnabled(this, "input_b")) << 1);
widget.serializeValue = executionNode => {
const flags = Number(branchIsEnabled(this, "input_a"))
| (Number(branchIsEnabled(this, "input_b")) << 1);
// Recent frontends resolve ignored virtual Get nodes before
// backend lazy execution, sometimes trying a nonexistent input.
// Drop only inactive links in the detached execution DTO;
// the editable graph and saved workflow keep all their wires.
if (executionNode !== this && executionNode?.node === this) {
for (const input of executionNode.inputs || []) {
const bit = input.name === "input_a" ? 1 : input.name === "input_b" ? 2 : 0;
if (bit && !(flags & bit) && "linkId" in input
&& !this.inputs.includes(input)) input.linkId = null;
}
}
return flags;
};
}
const internalSlot = this.inputs?.findIndex(input => input.name === "active_inputs");
if (internalSlot >= 0) this.removeInput(internalSlot);