Compare commits

..
8 Commits
Author SHA1 Message Date
adachi af3de6eafb Deduplicate replayed comparison previews 2026-10-16 13:44:26 +08:00
adachi 9a38504ea7 Keep comparison fitted and retain results across runs 2026-10-16 13:29:43 +08:00
adachi a9e2b7c168 Follow Get Set routes when selecting the enabled input 2026-10-16 13:14:04 +08:00
adachi d5cb95d2e1 Bind editor and panel widget widths to node geometry 2026-10-16 12:16:21 +08:00
adachi 95b09ee484 Isolate Fit button clicks and version inline editor assets 2026-10-16 12:03:13 +08:00
adachi 75d42c44d1 Place Fit beside Load image in the inline mask 2026-10-16 11:30:11 +08:00
adachi 5fcb150384 Support single-frame 5D VAE latents for Anima and other image models 2026-10-16 11:27:13 +08:00
adachi fdc70ede61 Set the Comfy Registry publisher id
The publisher account adachi0130 exists on the registry, so fill in
tool.comfy.PublisherId to enable publishing this node pack there.
2026-10-16 10:54:27 +08:00
19 changed files with 509 additions and 100 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
+46
View File
@@ -1,5 +1,51 @@
# Changelog
## 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.
- Keep native widget margins and vertical sizing; horizontal and vertical node resizing remain available.
- Calculate source-image automatic canvas height from untransformed client dimensions rather than dividing screen measurements by graph zoom.
- Verify stale-width fault injection, horizontal/vertical resize, zoom, Fit/mask preservation and workflow reload on frontend 1.42.11 and 1.52.7 in both renderers.
## 0.5.4
- Prevent the inline Fit button click from bubbling into ComfyUI node selection / bring-to-front handlers, which can remeasure a saved node's DOM editor.
- Keep node and editor dimensions unchanged when fitting the image. Fit still resets only image zoom and pan.
- Version the inline editor module and stylesheet URLs to refresh imported assets on upgrades; update an existing shared stylesheet link as well.
- Verify actual clicks on saved workflow node geometry in frontend 1.42.11 and 1.52.7, with classic nodes and Nodes 2.0.
## 0.5.3
- Move the image Fit button from the inpaint painting controls to the inline mask, immediately after Load image.
- Keep viewport reset and mask/history behavior unchanged. Includes the 0.5.2 single-frame 5D VAE compatibility fix.
## 0.5.2
- Support still-image inpainting with both 4D `[B,C,H,W]` and single-frame 5D `[B,C,1,H,W]` VAE latents, including the Wan-derived VAE used by Anima/Qwen-image pipelines.
- Preserve the independent image/variant batch axis when a video-derived VAE would otherwise interpret the batch as frames.
- Match noise/fill masks to latent rank and convert single-frame decoded images back to ordinary IMAGE batches before compositing.
- Keep native inpaint conditioning and its batch/mask metadata compatible with single-frame 5D latents.
- Add regression coverage and verify actual ComfyUI Wan VAE encode/decode and native mask preparation with tiny random test weights; no real Anima checkpoint was available locally.
## 0.5.1
- Add Auto Exclusive Switch: two generic lazy inputs, no manual switch control, and exactly one enabled branch required.
+11 -4
View File
@@ -86,7 +86,7 @@ When the mask touches an image edge, masked-area mode uses aspect-preserving fil
| Image / mask / overlay | Preview toggles only; they do not change the output |
Brush pressure and soft brushes are supported; the mouse uses a fixed pressure.
Inline Mask keeps only the image editing area and a small **Load image** button in the corner, and adjusts the canvas height to the image ratio after loading to reduce empty margins.
Inline Mask keeps only the image editing area and small **Load image** / **Fit** buttons in the corner, and adjusts the canvas height to the image ratio after loading to reduce empty margins.
The brush, eraser, rectangle, lasso, pan, four brush sliders, undo/redo, mask actions, view toggles, fit and the embed option all live in the connected **WebUI Inpaint** node and drive that same inline canvas.
You upload the image once; every stroke then updates the mask in the browser and is stored as PNG data in the workflow and in the executed parameters, so there is no saving and reloading of files per stroke.
Wheel events over the picture zoom it; over the toolbar, buttons, sliders or the parameter panel they follow ComfyUI's canvas navigation settings.
@@ -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.
@@ -186,7 +189,9 @@ Embedding grows the workflow file, and the source and mask then travel with the
This is an original WebUI-style implementation and does not promise pixel-perfect agreement with AUTOMATIC1111/Forge results.
`soft_edge` is extra Gaussian feathering with `soft_strength` controlling the extra radius; it **does not reimplement WebUI's Soft Inpainting algorithm or its full parameter set**.
Samplers, schedulers and Refiner use the current ComfyUI implementations, and the fill algorithms are this plugin's own.
Static images and 4-dimensional latents are supported; video VAEs, WebUI scripts/extensions and automatic reading of WebUI plugin configuration are not.
Static-image pipelines support ordinary 4D `[B,C,H,W]` latents and single-frame 5D `[B,C,1,H,W]` latents, without hardcoding a model name or channel count. This includes the Wan-derived VAE used by Anima and Qwen-image pipelines. Independent images/variants stay in the batch dimension, with their own masks; they are not encoded as consecutive video frames.
Use the matching model, VAE and text conditioning for the model family. For ordinary Anima img2img, keep **inpaint_model** disabled; that option invokes native conditioning for dedicated inpainting architectures, and is not required merely because a mask is painted.
Actual multi-frame video, audio/3D latents, model-specific control pipelines, WebUI scripts/extensions and automatic WebUI plugin configuration are not implemented.
Limits are 64 MiB per file, 32 megapixels per image and 16384 px per side; no models are bundled and nothing is downloaded.
Node loading, canvas editing, workflow/executed-parameter save and restore have been verified in an isolated CPU ComfyUI service on the development machine, and the backend is verified with mock VAE/samplers; real-checkpoint image quality and compatibility across every model family and Refiner have not been tested.
@@ -213,3 +218,5 @@ python -m unittest discover -s tests -v
新增「自动二选一」:把两个正面条件(或图像、模型等同类型数据)接到输入 A/B,输出接共同下游;忽略其中一个分支,就自动使用另一个。两路都启用或都无输入会报错,不需要 switch。切换节点放在两个被控制分组之外,也能配合组忽略预设使用。
Anima 兼容:支持其 Wan/Qwen VAE 的单帧五维潜空间,也保留普通四维 VAE;会同步适配蒙版、填充、批次和解码回贴。普通 Anima 图生图请保持“专用重绘模型”关闭,并使用配套模型、VAE 与文本条件;这不是多帧视频重绘功能。
+11 -3
View File
@@ -1,9 +1,17 @@
# Git versions and rollback
The current local version is **v0.5.1**. 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.8**. 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.8` | 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 |
| `local-before-v0.5.2` | Exact baseline before the VAE compatibility fix in each local repository |
| `v0.5.1` | Auto Exclusive Switch, wider preset list, and all v0.5.0 features |
| `v0.5.0` / `local-before-v0.5.1` | Preserve masks on adoption and group bypass presets |
| `local-before-v0.5.0` | Exact baseline before mask adoption / group preset changes |
@@ -12,13 +20,13 @@ To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach v0.5.0
git switch --detach local-before-v0.5.8
```
Return to this version with:
```powershell
git switch --detach v0.5.1
git switch --detach v0.5.8
```
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
+20
View File
@@ -31,6 +31,26 @@ Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.
- 0.5.1: 26 real CPU queue cases across classic nodes and Nodes 2.0 cover A/B selection, bypassed transform pass-through, mute, both-enabled/both-bypassed/both-disconnected errors, reroutes, one connected input, group presets, cache changes, workflow reload and switching reloaded presets. Output pixels match the selected image; the unused branch was not requested by the selector. A raw API prompt works without frontend metadata.
- 0.5.1: the 40%-width preset list retains internal scrolling and has no horizontal overflow in a 360px node at a 390px viewport.
- 0.5.2: all 29 CPU tests pass. New cases cover single-frame 5D image latents, per-source/variant masks, both crop modes, noise/zero fill, third-party VAE wrappers, native inpaint conditioning, refiner continuation, and explicit rejection of real multi-frame output.
- 0.5.2: actual ComfyUI `VAE` + `WanVAE` encode/decode and `prepare_mask` were exercised with a small randomly initialized Wan architecture (16 latent channels). Four cases cover whole-image/original, masked-area/noise, zero-latent fill, and native inpaint conditioning, with two independent sources and two variants each. Latents were `[4,16,1,8,8]`, masks `[4,1,1,8,8]`, decoded images `[4,64,64,3]`, and all protected source pixels remained exact.
- Anima checkpoint image generation was not performed: this machine does not have the user's Anima/Qwen VAE checkpoint files. The workflow was inspected read-only, confirming node 501 uses VAE node 358 (`qwen_image_vae.safetensors`) and the Anima model branch.
- 0.5.3: in classic nodes and Nodes 2.0, the inline Fit button is on the same row immediately to the right of Load image, and is absent from the connected inpaint controls. Actual browser clicks reset zoom/pan and preserve the painted mask PNG exactly.
- 0.5.4: frontend 1.52.7 was downloaded as a wheel and extracted into an isolated test directory, leaving the installed frontend unchanged. With saved node dimensions from the user's workflow, the previous bubbling Fit click changed the classic editor viewport from 396x625 to 396x594. Stopping propagation keeps it at 396x625. Nodes 2.0 also retains its viewport and node dimensions.
- 0.5.4: actual browser clicks pass with the saved workflow geometry on frontend 1.42.11 and 1.52.7 in both renderers. Fit resets zoom/pan, remains immediately after Load image, and the inpaint panel occupies over 94% of its node's logical width. The remote server and its installed extension set were not accessed, so the separate narrow-panel screenshot cannot be claimed reproduced/resolved remotely.
- 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.
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:
+71 -9
View File
@@ -396,6 +396,58 @@ def _vae_ratio(vae):
return int(ratio) if isinstance(ratio, (int, float)) and ratio >= 1 else 8
def _check_image_latents(samples, batch):
if not isinstance(samples, torch.Tensor) or samples.ndim not in (4, 5) or min(samples.shape) < 1:
raise ValueError("Image VAE must return [B,C,H,W] or [B,C,1,H,W] latents.")
if samples.shape[0] != batch:
raise ValueError("VAE latent batch must match the number of independent input images.")
if samples.ndim == 5 and samples.shape[2] != 1:
raise ValueError("Still-image inpainting requires one latent frame per image (T=1); multi-frame video is not supported.")
return samples
def _encode_still_images(vae, pixels):
# Video-derived VAEs can interpret a 4D IMAGE batch as consecutive frames.
# Encode those VAEs one image at a time so sources/variants remain independent.
if getattr(vae, "latent_dim", None) == 3 and pixels.shape[0] > 1:
samples = torch.cat([_check_image_latents(vae.encode(frame[None]), 1) for frame in pixels], dim=0)
else:
samples = vae.encode(pixels)
# Also support third-party VAE wrappers without a latent_dim attribute.
if isinstance(samples, torch.Tensor) and samples.ndim == 5 and pixels.shape[0] > 1:
if samples.shape[0] != pixels.shape[0] or samples.shape[2] != 1:
samples = torch.cat([_check_image_latents(vae.encode(frame[None]), 1) for frame in pixels], dim=0)
return _check_image_latents(samples, pixels.shape[0])
class _StillImageVAE:
"""Keep native inpaint conditioning while making its VAE encodes batch-safe."""
def __init__(self, vae):
self.vae = vae
def __getattr__(self, name):
return getattr(self.vae, name)
def encode(self, pixels):
return _encode_still_images(self.vae, pixels)
def _decode_still_images(vae, samples, batch):
_check_image_latents(samples, batch)
decoded = vae.decode(samples)
if isinstance(decoded, tuple):
decoded = decoded[0]
if isinstance(decoded, torch.Tensor) and decoded.ndim == 5:
if decoded.shape[1] != 1:
raise ValueError("VAE decoded multiple frames for a still image; expected [B,1,H,W,C].")
decoded = decoded[:, 0]
decoded = _image_tensor(decoded)
if decoded.shape[0] != batch:
raise ValueError("VAE decoded image batch does not match the input image batch.")
return decoded
def _check_refiner(model, refiner):
"""Reject known incompatible latent formats while allowing SDXL base/refiner."""
first = getattr(getattr(model, "model", None), "latent_format", None)
@@ -478,13 +530,26 @@ class AdachiWebUIInpaint:
masks = _resize_mask(masks, target_height, target_width)
if inpaint_model:
positive, negative, latent = nodes.InpaintModelConditioning().encode(
positive=positive, negative=negative, pixels=pixels, vae=vae, mask=masks, noise_mask=True)
positive=positive, negative=negative, pixels=pixels, vae=_StillImageVAE(vae), mask=masks, noise_mask=True)
else:
latent = {"samples": vae.encode(pixels)}
samples = latent["samples"]
if not isinstance(samples, torch.Tensor) or samples.ndim != 4:
raise ValueError("This node supports still-image VAEs with 4D image latents.")
latent = {"samples": _encode_still_images(vae, pixels)}
samples = _check_image_latents(latent["samples"], pixels.shape[0])
latent_mask = _resize_mask(masks.to(device=samples.device, dtype=samples.dtype), samples.shape[-2], samples.shape[-1])[:, None]
if samples.ndim == 5:
latent_mask = latent_mask.unsqueeze(2)
if inpaint_model:
# Native conditioning provides a pixel-space [B,1,H,W] mask;
# retain the independent batch axis for volumetric model inputs.
def volumetric_conditioning(conditioning):
result = []
for embedding, metadata in conditioning:
metadata = metadata.copy()
concat_mask = metadata.get("concat_mask")
if isinstance(concat_mask, torch.Tensor) and concat_mask.ndim == 4:
metadata["concat_mask"] = concat_mask.unsqueeze(2)
result.append([embedding, metadata])
return result
positive, negative = volumetric_conditioning(positive), volumetric_conditioning(negative)
latent["noise_mask"] = latent_mask
fill = kwargs.get("fill", "original")
if fill in {"latent_noise", "latent_nothing"}:
@@ -512,10 +577,7 @@ class AdachiWebUIInpaint:
else:
sampled = nodes.common_ksampler(model, int(seed), int(steps), float(cfg), sampler_name, scheduler,
positive, negative, latent, denoise=float(denoise))[0]
decoded = vae.decode(sampled["samples"])
if isinstance(decoded, tuple):
decoded = decoded[0]
decoded = _image_tensor(decoded)
decoded = _decode_still_images(vae, sampled["samples"], pixels.shape[0])
return _composite(decoded, context), decoded, masks
+2 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "adachi-inpaint-workbench"
version = "0.5.1"
version = "0.5.8"
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
@@ -18,9 +18,7 @@ Documentation = "https://github.com/adachi0130/Adachi-Inpaint-Workbench#readme"
"Bug Tracker" = "https://github.com/adachi0130/Adachi-Inpaint-Workbench/issues"
[tool.comfy]
# Set this to your actual Comfy Registry publisher ID before Registry publishing.
# GitHub publication and the Manager legacy-list submission do not require it.
PublisherId = ""
PublisherId = "adachi0130"
DisplayName = "Adachi-Inpaint-Workbench"
[tool.adachi-workbench]
+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");
+134
View File
@@ -0,0 +1,134 @@
"""Regression coverage for image models using single-frame video-derived VAEs."""
import importlib.util
from pathlib import Path
import sys
import types
import unittest
from unittest.mock import patch
import torch
import torch.nn.functional as F
spec = importlib.util.spec_from_file_location("vae_format_backend", Path(__file__).resolve().parents[1] / "backend.py")
backend = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backend)
class VideoDerivedVAE:
latent_dim = 3
downscale_ratio = (lambda t: (t + 3) // 4, 8, 8)
def __init__(self):
self.encoded_batches = []
def spacial_compression_encode(self):
return 8
def encode(self, pixels):
self.encoded_batches.append(pixels.shape[0])
# Like Wan/Qwen VAE, an IMAGE batch would otherwise become time.
frames = F.interpolate(pixels.mean(-1)[:, None], scale_factor=1 / 8, mode="bilinear", align_corners=False)
return frames.permute(1, 0, 2, 3)[None].repeat(1, 16, 1, 1, 1)
def decode(self, samples):
image = F.interpolate(samples[:, :1, 0], scale_factor=8, mode="nearest")
return image.repeat(1, 3, 1, 1).permute(0, 2, 3, 1)[:, None]
class ImageVAEFormatTests(unittest.TestCase):
def inputs(self):
image = torch.stack([torch.full((64, 64, 3), .2), torch.full((64, 64, 3), .8)])
masks = torch.zeros(2, 64, 64)
masks[0, :, :32] = 1
masks[1, :, 32:] = 1
return image, masks
def test_5d_latents_preserve_batch_masks_compositing_and_variants(self):
for area in ("whole_image", "only_masked"):
with self.subTest(area=area):
image, masks = self.inputs()
vae, captured = VideoDerivedVAE(), []
def sample(*args, **kwargs):
captured.append(args[8])
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample)}):
result, crop, mask = backend.AdachiWebUIInpaint().sample(None, [], [], vae, image, masks,
width=64, height=64, area=area, mask_blur=0, batch_size=2)
self.assertEqual(vae.encoded_batches, [1, 1, 1, 1])
self.assertEqual(captured[0]["samples"].shape, (4, 16, 1, 8, 8))
self.assertEqual(captured[0]["noise_mask"].shape, (4, 1, 1, 8, 8))
self.assertTrue(torch.allclose(captured[0]["samples"].mean((1, 2, 3, 4)), torch.tensor([.2, .2, .8, .8])))
originals, masks = image.repeat_interleave(2, 0), masks.repeat_interleave(2, 0)
self.assertEqual(result.shape, originals.shape)
self.assertEqual(crop.shape, (4, 64, 64, 3))
self.assertEqual(mask.shape, (4, 64, 64))
self.assertTrue(torch.equal(result[masks == 0], originals[masks == 0]))
def test_5d_noise_and_zero_fill_only_modify_masked_latents(self):
image, masks = self.inputs()
captured = []
def sample(*args, **kwargs):
captured.append(args[8])
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample)}):
for fill in ("latent_noise", "latent_noise", "latent_nothing"):
backend.AdachiWebUIInpaint().sample(None, [], [], VideoDerivedVAE(), image, masks,
width=64, height=64, area="whole_image", mask_blur=0, fill=fill, seed=42)
self.assertTrue(torch.equal(captured[0]["samples"], captured[1]["samples"]))
for latent in captured:
protected = (latent["noise_mask"] == 0).expand_as(latent["samples"])
expected = torch.tensor([.2, .8])[:, None, None, None, None].expand_as(latent["samples"])
self.assertTrue(torch.allclose(latent["samples"][protected], expected[protected]))
selected = (captured[2]["noise_mask"] == 1).expand_as(captured[2]["samples"])
self.assertTrue(torch.equal(captured[2]["samples"][selected], torch.zeros_like(captured[2]["samples"][selected])))
self.assertFalse(torch.equal(captured[0]["samples"][0], captured[0]["samples"][1]))
def test_unknown_vae_wrapper_recovers_independent_batch(self):
vae = VideoDerivedVAE()
wrapper = types.SimpleNamespace(encode=vae.encode)
samples = backend._encode_still_images(wrapper, self.inputs()[0])
self.assertEqual(samples.shape, (2, 16, 1, 8, 8))
self.assertEqual(vae.encoded_batches, [2, 1, 1])
def test_native_inpaint_conditioning_keeps_5d_batch_axis(self):
image, masks = self.inputs()
original_meta = {"marker": "retained"}
conditioning = [[torch.zeros(1, 2, 3), original_meta]]
class Native:
def encode(self, positive, negative, pixels, vae, mask, noise_mask):
metadata = dict(original_meta, concat_latent_image=vae.encode(pixels), concat_mask=mask[:, None])
return [[positive[0][0], metadata]], [[negative[0][0], metadata]], {"samples": vae.encode(pixels), "native_field": True}
def sample(*args, **kwargs):
self.assertEqual(args[6][0][1]["concat_mask"].shape, (2, 1, 1, 64, 64))
self.assertEqual(args[6][0][1]["concat_latent_image"].shape, (2, 16, 1, 8, 8))
self.assertTrue(args[8]["native_field"])
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample, InpaintModelConditioning=Native)}):
backend.AdachiWebUIInpaint().sample(None, conditioning, conditioning, VideoDerivedVAE(), image, masks,
width=64, height=64, mask_blur=0, inpaint_model=True)
self.assertEqual(original_meta, {"marker": "retained"})
def test_refiner_preserves_single_frame_shape(self):
image, masks = self.inputs()
calls = []
def sample(*args, **kwargs):
calls.append(args[8]["samples"].shape)
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample)}):
backend.AdachiWebUIInpaint().sample(None, [], [], VideoDerivedVAE(), image, masks, refiner_model=object(),
width=64, height=64, mask_blur=0)
self.assertEqual(calls, [(2, 16, 1, 8, 8)] * 2)
def test_actual_video_or_invalid_vae_outputs_fail_explicitly(self):
for samples in (torch.zeros(1, 16, 2, 8, 8), torch.zeros(1, 8, 8), "bad"):
with self.subTest(type=type(samples)), self.assertRaises(ValueError):
backend._check_image_latents(samples, 1)
with self.assertRaisesRegex(ValueError, "batch"):
backend._check_image_latents(torch.zeros(1, 4, 8, 8), 2)
vae = types.SimpleNamespace(decode=lambda latent: torch.zeros(1, 2, 64, 64, 3))
with self.assertRaisesRegex(ValueError, "multiple frames"):
backend._decode_still_images(vae, torch.zeros(1, 16, 1, 8, 8), 1)
if __name__ == "__main__":
unittest.main()
+11 -40
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,7 +28,7 @@ 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"));
@@ -46,34 +45,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 +127,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();
}
@@ -197,7 +169,6 @@ export class ImageCompareView {
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;
}
+18 -5
View File
@@ -1,8 +1,10 @@
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.8";
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";
function viewUrl(image) {
const type = ["input", "output", "temp"].includes(image?.type) ? image.type : "temp";
@@ -50,23 +52,34 @@ 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,
});
widget.serialize = false;
bindWidgetToNodeWidth(node, widget, compare.root);
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;
+6 -1
View File
@@ -170,7 +170,11 @@ export class InlineMaskEditor {
footer.append(spacer);
this.zoomLabel = el("span", "dd-zoom", "100%");
footer.append(this.zoomLabel);
this.button(footer, T("Fit"), T("Fit the image to the canvas"), () => this.fit());
this.fitButton = this.button(footer, T("Fit"), T("Fit the image to the canvas"), () => this.fit());
// The inline button must not select/bring forward the ComfyUI node:
// newer frontends can remeasure its DOM widget on that bubbling click.
const fitAction = this.fitButton.onclick;
this.fitButton.onclick = event => { event.stopPropagation(); fitAction(event); };
this.root.append(footer);
const embedRow = el("label", "dd-embed");
this.embed = el("input");
@@ -189,6 +193,7 @@ export class InlineMaskEditor {
const info = el("div", "dd-row dd-source-info"); info.append(this.filename, this.sizeLabel);
head.remove();
this.controls.append(info, tools, settings, actions, footer, embedRow);
this.loadButton.after(this.fitButton);
// These are the original live controls; the sampler mounts this
// same DOM tree so brush state and callbacks are preserved.
}
+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);
+14 -14
View File
@@ -1,17 +1,16 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { InlineMaskEditor } from "./editor.js";
import { installInpaintWorkbench } from "./inpaint_node.js";
import { InlineMaskEditor } from "./editor.js?v=0.5.5";
import { installInpaintWorkbench } from "./inpaint_node.js?v=0.5.5";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
const styleId = "adachi-inpaint-style";
if (!document.getElementById(styleId)) {
const link = document.createElement("link");
link.id = styleId; link.rel = "stylesheet";
link.href = new URL("./editor.css", import.meta.url).href;
document.head.append(link);
}
const stylesheet = document.getElementById(styleId) || document.createElement("link");
stylesheet.id = styleId; stylesheet.rel = "stylesheet";
stylesheet.href = new URL("./editor.css?v=0.5.5", import.meta.url).href;
if (!stylesheet.isConnected) document.head.append(stylesheet);
function hide(widget) {
if (!widget) return;
@@ -65,12 +64,11 @@ app.registerExtension({
cancelAnimationFrame(fitFrame);
fitFrame = requestAnimationFrame(() => {
if (!editor.ready || editor.destroyed || !node.graph) return;
const rect = editor.canvas.getBoundingClientRect();
const scale = app.canvas?.ds?.scale || 1;
if (!rect.width || !rect.height) return;
const overhead = node.size[1] - rect.height / scale;
const height = overhead + rect.width / scale * editor.image.height / editor.image.width;
node.setSize([node.size[0], Math.max(240, Math.min(4200, Math.ceil(height)))]);
const width = editor.canvas.clientWidth, height = editor.canvas.clientHeight;
if (!width || !height) return;
const overhead = node.size[1] - height;
const fittedHeight = overhead + width * editor.image.height / editor.image.width;
node.setSize([node.size[0], Math.max(240, Math.min(4200, Math.ceil(fittedHeight)))]);
node.properties.adachi_compact_ui = true;
editor.schedule();
});
@@ -117,6 +115,7 @@ app.registerExtension({
markChanged();
},
});
editor.root.dataset.adachiVersion = "0.5.5";
node.adachiEditor = editor;
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
node.adachiUseResult = async file => {
@@ -132,6 +131,7 @@ app.registerExtension({
getMaxHeight: () => 4096,
});
domWidget.serialize = false;
bindWidgetToNodeWidth(node, domWidget, editor.root);
// Leave computeLayoutSize in charge: a fixed computeSize height
// prevents the DOM canvas from growing with the node vertically.
const resizeHandle = document.createElement("div");
+2
View File
@@ -3,6 +3,7 @@ import { InpaintParameterPanel } from "./inpaint_panel.js";
import { connectedEditor, connectedSamplers, relocateWidgets } from "./parameter_bridge.js";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
export function installInpaintWorkbench(nodeType) {
const created = nodeType.prototype.onNodeCreated;
@@ -58,6 +59,7 @@ export function installInpaintWorkbench(nodeType) {
serialize: false, hideOnZoom: false, getMinHeight: contentHeight, getMaxHeight: () => Math.max(4096, contentHeight()),
});
widget.serialize = false;
bindWidgetToNodeWidth(node, widget, root);
const observer = new ResizeObserver(scheduleSize); observer.observe(tools);
const configured = node.onConfigure;
node.onConfigure = function () { const result = configured?.apply(this, arguments); queueMicrotask(() => { if (!removed) { panel.refresh(); scheduleSize(); } }); return result; };
+16
View File
@@ -0,0 +1,16 @@
/** Fill the node in graph coordinates, independently of cached DOM measurements. */
export function bindWidgetToNodeWidth(node, widget, element) {
// Classic ComfyUI uses widget.width before node.width for its DOM host.
// A cached screen-space width is scaled a second time when zoomed out.
// These single-column widgets always occupy the node's available width;
// the frontend still applies its own widget margins and vertical layout.
Object.defineProperty(widget, "width", {
configurable: true,
enumerable: true,
get: () => Math.max(1, Number(node.size[0]) || 1),
set: () => {},
});
element.style.width = "100%";
element.style.maxWidth = "100%";
element.style.minWidth = "0";
}