Compare commits

..
10 Commits
Author SHA1 Message Date
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
adachi a6657bac72 Translate the preset names and drop locale snapshots in examples 03/04
The group preset display names and the group titles stored inside each preset
were still Chinese, and the slots carried localized_name snapshots from the
author's machine. Translate the user-visible names to English and drop the
snapshots so every reader sees their own ComfyUI locale.
2026-10-16 10:47:33 +08:00
adachi 977c352361 Make the new example workflows English-consistent
Examples 03 and 04 kept Chinese note text, Chinese group names and a saved
Chinese node title, unlike examples 01 and 02. Translate the notes and group
names to English and drop the AdachiGroupPresets title override so the node
falls back to its locale-aware name, which keeps the workflows consistent and
still shows Chinese in a Chinese ComfyUI.
2026-10-16 10:45:43 +08:00
adachi 540f3c0c45 Keep the new nodes English-only
The exclusive switch and group preset work reintroduced Chinese text into
backend error messages and a frontend node description, which contradicts the
English-by-default rule that the Manager listing follows. Move those strings
back to English and route the frontend description through the translation
dictionary, so a Chinese ComfyUI still shows Chinese via locales/ and the
canvas dictionary.
2026-10-16 10:32:10 +08:00
adachi b2087ba565 Add automatic exclusive input switch and widen preset list 2026-10-16 10:25:29 +08:00
Adachi fa644d9969 Preserve masks on adoption and add group bypass presets 2026-10-16 09:38:55 +08:00
23 changed files with 1623 additions and 48 deletions
+41
View File
@@ -1,5 +1,46 @@
# Changelog
## 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.
- Respect muted/bypassed input branches, including bypassed transform nodes that normally pass through data; branch changes also invalidate execution caches.
- Widen the group preset list from 32% to 40% of its panel.
- Keep the new nodes English-only as well: backend error messages and the group preset node description no longer carry inline Chinese, and the two new example workflows use English note text and group names. The group preset node keeps no saved title override, so it shows its locale-aware name in any language.
## 0.5.0
- Preserve the painted mask, undo/redo history and viewport when adopting a comparison result as the source; an ordinary image upload still starts a new mask.
- Add Group Bypass Presets: a connection-free controller with a preset list, per-group enabled/bypassed/unmanaged states, and a right-click preset menu.
- Store presets in the workflow with stable group IDs, support renamed and nested groups, and leave newly added/unmanaged groups untouched.
- Add a model-free example workflow for group presets.
## 0.4.2
- Make every user-facing string English by default: node display names, category, descriptions, tooltips and the canvas/panel labels.
+38 -3
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.
@@ -105,11 +105,25 @@ Parameters driven by an external link disable the matching panel control; a modu
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.
When you are happy, click **Adopt this result**: the plugin uploads the selected result as the new source image, writes it back into the upstream inline editor and clears the old mask, so you can paint the next area straight away.
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.
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
Search for **Group Bypass Presets** (Chinese: **组忽略预设**) under Adachi-Inpaint-Workbench. It needs no connections and does not require rgthree or Danbooru.
1. Click **+ New preset**, name it and save. The preset captures the current groups' enabled/bypassed state.
2. Choose **Enabled**, **Bypassed** or **Keep current** for each group on the right. Changes update and apply the selected preset immediately.
3. Add more presets. Clicking a name on the left applies that preset to all of its configured groups at once. You can also right-click the node and choose **Apply preset**.
4. Use **Save current group states** to capture changes you made directly in the workflow, or rename/delete presets as needed.
Presets are stored in the workflow. Group renames retain their preset mapping; newly added groups stay unmanaged until configured. Loading a workflow restores its saved node modes without automatically applying a preset. If groups overlap, the smaller group's setting takes priority; the preset controller itself stays enabled.
Bypass uses ComfyUI's standard bypass mode, so a bypassed node still needs compatible input/output types when it sits on an active data path.
Try [examples/03-group-presets.json](examples/03-group-presets.json), which uses only empty images and colour inversion and needs no model.
## Fill, batch and Refiner
| `fill` | Behaviour |
@@ -128,6 +142,21 @@ You can optionally attach a compatible `refiner_model` and use `refiner_switch`
Encoding with the Refiner's own CLIP and wiring `refiner_positive`/`refiner_negative` is recommended; if left unconnected the original conditioning is reused and the VAE still comes from the base side.
`refiner_switch=1` uses only the base model and `0` uses only the Refiner; a known-incompatible latent format raises an error.
## Auto Exclusive Switch
Search for **Auto Exclusive Switch** / **自动二选一** under **Adachi-Inpaint-Workbench**.
Connect two alternative branches to `input_a` / `input_b` and connect `output` to the common downstream input.
For example, connect the CONDITIONING outputs of two positive CLIP Text Encode nodes to A/B, then connect `output` to the sampler's positive input.
Bypass one encoder (Ctrl+B), or use a Group Bypass Preset to enable one group and bypass the other: the remaining branch is selected automatically.
Both enabled, or neither connected/enabled, produces an error instead of silently preferring A.
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.
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.
## Using a standard sampling workflow
`examples/02-modular-inpaint.json` shows a wiring you can swap samplers into.
@@ -157,7 +186,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.
@@ -182,3 +213,7 @@ python -m unittest discover -s tests -v
英文用户看到的一直是英文(英文是内置默认值);某个词条若没有对应翻译,会自动回退为英文。
两套语言共用同一个节点类 ID 和参数键名,所以已保存的工作流不受语言切换影响。
新增「自动二选一」:把两个正面条件(或图像、模型等同类型数据)接到输入 A/B,输出接共同下游;忽略其中一个分支,就自动使用另一个。两路都启用或都无输入会报错,不需要 switch。切换节点放在两个被控制分组之外,也能配合组忽略预设使用。
Anima 兼容:支持其 Wan/Qwen VAE 的单帧五维潜空间,也保留普通四维 VAE;会同步适配蒙版、填充、批次和解码回贴。普通 Anima 图生图请保持“专用重绘模型”关闭,并使用配套模型、VAE 与文本条件;这不是多帧视频重绘功能。
+17 -8
View File
@@ -1,21 +1,30 @@
# Git versions and rollback
`v0.2.0` is the first UI rollback point; `v0.3.0` added the inline parameter panel, the result/original comparison change and the wheel routing fix; `v0.4.0` moved the painting tools and parameters into the WebUI Inpaint node; `v0.4.1` refreshed the eraser cursor and the public name.
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.
Both the local installation folder and the source folder keep Git history; a normal release ZIP does not contain `.git`, so use the Git bundle to restore history separately.
| Tag | Contents |
|---|---|
| `v0.5.5` | 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 |
Run these commands in the plugin folder. Check there are no unsaved changes before switching versions.
To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach v0.4.1
git switch --detach local-before-v0.5.5
```
Return to the current version with:
Return to this version with:
```powershell
git switch main
git switch --detach v0.5.5
```
Restart ComfyUI and refresh the browser with `Ctrl+F5` afterwards.
If you have local modifications, commit or back them up first so they are not overwritten.
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
Commit or back up any local modifications before switching. A release ZIP does not contain `.git`; local history is kept in the source and installation repositories.
+19
View File
@@ -23,6 +23,25 @@ Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.
- 0.4.1: a real browser confirms the eraser SVG cursor and erase state after holding Alt, restoring the cross brush cursor on release; temporary erase, regular painting and the 18 backend CPU regression tests pass.
- 0.4.1: the public name was unified to Adachi-Inpaint-Workbench while node class IDs were kept so existing workflows stay compatible.
- 0.4.2: node display names, descriptions, tooltips and frontend labels were converted to English so the nodes are usable for the general Manager audience; the same 18 backend CPU tests pass and every touched script parses.
- 0.5.0: in classic nodes and Nodes 2.0, adopting a real queued comparison result preserves mask PNG pixels, base-mask pixels and the current undo/redo command arrays. Undo/redo still work, workflow reload retains the mask, a mismatched result leaves the source/mask unchanged, and ordinary image upload still clears the mask.
- 0.5.0: group preset tests cover real preset-button clicks, enabled/bypassed modes in executed prompts, exclusion of the virtual controller from backend execution, saved workflow reload, renamed and duplicate-title groups, unmanaged new groups, nested group priority, missing groups and protection of the controller itself.
- 0.5.0: the native right-click preset submenu was clicked successfully in both renderers. The example workflow was loaded and all three presets queued successfully without a model.
- 0.5.1: all 23 CPU unit tests pass, including exclusive input validation and identity-preserving forwarding of conditioning, image/mask tensors, latents and falsey values.
- 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.
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.
+125 -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
@@ -553,12 +615,65 @@ class AdachiImageCompare:
return {"ui": {"adachi_originals": before, "adachi_results": after}, "result": (result,)}
_MISSING_INPUT = object()
class AdachiExclusiveSwitch:
"""Forward exactly one enabled branch, without evaluating the ignored branch."""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"input_a": ("*", {"lazy": True, "tooltip": "First branch; bypass either this branch or input_b."}),
"input_b": ("*", {"lazy": True, "tooltip": "Second branch; exactly one branch must remain enabled."}),
# The frontend computes this from the graph on every serialization.
# Keeping it in the prompt also makes branch changes invalidate caches,
# including bypassed transform nodes whose links otherwise pass through.
"active_inputs": ("INT", {"default": -1, "min": -1, "max": 3}),
},
}
RETURN_TYPES = ("*",)
RETURN_NAMES = ("output",)
FUNCTION = "select"
CATEGORY = CATEGORY
DESCRIPTION = "Automatically forwards the only enabled input. Bypass one branch; both enabled or both absent is an error."
SEARCH_ALIASES = ["exclusive switch", "auto switch", "自动二选一", "互斥", "自动切换", "adachi"]
@staticmethod
def _selected_input(input_a, input_b, active_inputs):
if active_inputs == -1:
# Raw API prompts need no frontend state: simply omit the unused input.
active_inputs = int(input_a is not _MISSING_INPUT) | (int(input_b is not _MISSING_INPUT) << 1)
if active_inputs not in (1, 2):
raise ValueError(
"Auto Exclusive Switch requires exactly one enabled input. "
"Bypass one branch or disconnect it; both enabled / both absent are invalid."
)
name = "input_a" if active_inputs == 1 else "input_b"
if (input_a if active_inputs == 1 else input_b) is _MISSING_INPUT:
raise ValueError(f"The enabled branch {name} has no input.")
return name
def check_lazy_status(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1):
return [self._selected_input(input_a, input_b, active_inputs)]
def select(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1):
selected = self._selected_input(input_a, input_b, active_inputs)
value = input_a if selected == "input_a" else input_b
if value is None:
raise ValueError(f"The enabled branch {selected} returned no data.")
return (value,)
NODE_CLASS_MAPPINGS = {
"AdachiInlineMask": AdachiInlineMask,
"AdachiInpaintPrepare": AdachiInpaintPrepare,
"AdachiInpaintComposite": AdachiInpaintComposite,
"AdachiWebUIInpaint": AdachiWebUIInpaint,
"AdachiImageCompare": AdachiImageCompare,
"AdachiExclusiveSwitch": AdachiExclusiveSwitch,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -567,4 +682,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"AdachiInpaintComposite": "Inpaint Composite",
"AdachiWebUIInpaint": "WebUI Inpaint",
"AdachiImageCompare": "Image Compare",
"AdachiExclusiveSwitch": "Auto Exclusive Switch",
}
+335
View File
@@ -0,0 +1,335 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 6,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "EmptyImage",
"pos": [
-350,
140
],
"size": [
270,
160
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
},
{
"name": "color",
"type": "INT",
"widget": {
"name": "color"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
]
}
],
"properties": {
"Node name for S&R": "EmptyImage"
},
"widgets_values": [
512,
512,
1,
8096934
]
},
{
"id": 2,
"type": "ImageInvert",
"pos": [
120,
160
],
"size": [
225,
48
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
},
"widgets_values": []
},
{
"id": 3,
"type": "ImageInvert",
"pos": [
510,
160
],
"size": [
225,
48
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 2
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
},
"widgets_values": []
},
{
"id": 4,
"type": "SaveImage",
"pos": [
920,
140
],
"size": [
270,
76
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"Adachi/GroupPresetExample"
]
},
{
"id": 5,
"type": "AdachiGroupPresets",
"pos": [
80,
440
],
"size": [
620,
410
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"adachi_group_presets": {
"version": 1,
"activeId": "01dbf1d1-c15a-4db1-97d5-9d522781c482",
"presets": [
{
"id": "01dbf1d1-c15a-4db1-97d5-9d522781c482",
"name": "All enabled",
"groups": {
"group:1": {
"title": "Base",
"mode": 0
},
"group:2": {
"title": "Detail",
"mode": 0
}
}
},
{
"id": "fe43bc1f-1a1e-4f49-a37b-c6e6ca7cbc8a",
"name": "Skip detail",
"groups": {
"group:1": {
"title": "Base",
"mode": 0
},
"group:2": {
"title": "Detail",
"mode": 4
}
}
},
{
"id": "b881a666-4643-4af6-8feb-f1d02c47f113",
"name": "Bypass both",
"groups": {
"group:1": {
"title": "Base",
"mode": 4
},
"group:2": {
"title": "Detail",
"mode": 4
}
}
}
]
}
}
},
{
"id": 6,
"type": "Note",
"pos": [
820,
550
],
"size": [
350,
180
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Adachi-Inpaint-Workbench - Group Bypass Presets\nSelect a preset on the left to switch a whole group between enabled and bypassed; no connections required\nAdjust each group's state on the right, or click \"Save current group states\" to store the current workflow\nRight-click the node -> Apply preset to switch quickly\nThis example only inverts an image, so no model is needed"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
2,
2,
0,
3,
0,
"IMAGE"
],
[
3,
3,
0,
4,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "Base",
"bounding": [
80,
100,
300,
230
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Detail",
"bounding": [
470,
100,
300,
230
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}
+423
View File
@@ -0,0 +1,423 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "EmptyImage",
"pos": [
0,
120
],
"size": [
270,
130
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
},
{
"name": "color",
"type": "INT",
"widget": {
"name": "color"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
]
}
],
"properties": {
"Node name for S&R": "EmptyImage"
},
"widgets_values": [
64,
64,
1,
16711680
]
},
{
"id": 2,
"type": "EmptyImage",
"pos": [
0,
450
],
"size": [
270,
130
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
},
{
"name": "color",
"type": "INT",
"widget": {
"name": "color"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "EmptyImage"
},
"widgets_values": [
64,
64,
1,
255
]
},
{
"id": 3,
"type": "ImageInvert",
"pos": [
300,
120
],
"size": [
140,
26
],
"flags": {},
"order": 3,
"mode": 4,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
}
},
{
"id": 4,
"type": "ImageInvert",
"pos": [
300,
450
],
"size": [
140,
26
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 2
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
}
},
{
"id": 5,
"type": "AdachiExclusiveSwitch",
"pos": [
620,
200
],
"size": [
230,
80
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "input_a",
"shape": 7,
"type": "*",
"link": 3
},
{
"name": "input_b",
"shape": 7,
"type": "*",
"link": 4
}
],
"outputs": [
{
"name": "output",
"type": "*",
"links": [
5
]
}
],
"properties": {
"Node name for S&R": "AdachiExclusiveSwitch"
},
"widgets_values": [
-1
]
},
{
"id": 6,
"type": "SaveImage",
"pos": [
940,
200
],
"size": [
270,
58
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 5
},
{
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"exclusive-test"
]
},
{
"id": 7,
"type": "AdachiGroupPresets",
"pos": [
600,
430
],
"size": [
620,
410
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"adachi_group_presets": {
"version": 1,
"activeId": "2908ca1a-7a3f-4eba-8203-524ab3f52d27",
"presets": [
{
"id": "dd436f10-d4a4-46bc-aecb-fe185710f6e0",
"name": "Use A only",
"groups": {
"group:1": {
"title": "A",
"mode": 0
},
"group:2": {
"title": "B",
"mode": 4
}
}
},
{
"id": "2908ca1a-7a3f-4eba-8203-524ab3f52d27",
"name": "Use B only",
"groups": {
"group:1": {
"title": "A",
"mode": 4
},
"group:2": {
"title": "B",
"mode": 0
}
}
}
]
}
}
}
],
"links": [
[
1,
1,
0,
3,
0,
"IMAGE"
],
[
2,
2,
0,
4,
0,
"IMAGE"
],
[
3,
3,
0,
5,
0,
"IMAGE"
],
[
4,
4,
0,
5,
1,
"IMAGE"
],
[
5,
5,
0,
6,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "A",
"bounding": [
270,
70,
310,
210
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "B",
"bounding": [
270,
400,
310,
210
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.8,
"offset": [
100,
20
]
}
},
"version": 0.4
}
+19
View File
@@ -228,5 +228,24 @@
"name": "image"
}
}
},
"AdachiExclusiveSwitch": {
"display_name": "Auto Exclusive Switch",
"description": "Automatically forwards the only enabled input. Bypass one branch; both enabled or both absent is an error.",
"inputs": {
"input_a": {
"name": "input_a",
"tooltip": "First branch. Connect the same data type to both branches and the output."
},
"input_b": {
"name": "input_b",
"tooltip": "Second branch. Exactly one branch must remain enabled."
}
},
"outputs": {
"0": {
"name": "output"
}
}
}
}
+19
View File
@@ -232,5 +232,24 @@
"name": "图像"
}
}
},
"AdachiExclusiveSwitch": {
"display_name": "自动二选一 / Auto Exclusive Switch",
"description": "自动输出唯一启用的分支;忽略或断开另一路,两路都启用或都无输入时提示错误,无需 switch。",
"inputs": {
"input_a": {
"name": "输入 A",
"tooltip": "第一路;两路及下游应使用相同数据类型,如正面条件、图像、模型。"
},
"input_b": {
"name": "输入 B",
"tooltip": "第二路;两路中需要且只能启用一路,另一支设为忽略或断开。"
}
},
"outputs": {
"0": {
"name": "输出"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+2 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "adachi-inpaint-workbench"
version = "0.4.2"
version = "0.5.5"
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]
+47
View File
@@ -0,0 +1,47 @@
"""The selector must preserve arbitrary values and never silently prefer a branch."""
import importlib.util
from pathlib import Path
import unittest
import torch
spec = importlib.util.spec_from_file_location("exclusive_backend", Path(__file__).resolve().parents[1] / "backend.py")
backend = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backend)
class ExclusiveSwitchTests(unittest.TestCase):
def setUp(self):
self.node = backend.AdachiExclusiveSwitch()
def test_single_connected_branch_is_requested(self):
self.assertEqual(self.node.check_lazy_status(input_a=None), ["input_a"])
self.assertEqual(self.node.check_lazy_status(input_b=None), ["input_b"])
def test_bypassed_passthrough_branch_is_not_requested(self):
self.assertEqual(self.node.check_lazy_status(input_a=None, input_b=None, active_inputs=1), ["input_a"])
self.assertEqual(self.node.check_lazy_status(input_a=None, input_b=None, active_inputs=2), ["input_b"])
a, b = object(), object()
self.assertIs(self.node.select(a, b, 1)[0], a)
self.assertIs(self.node.select(a, b, 2)[0], b)
def test_ambiguous_and_empty_inputs_fail(self):
for inputs in ({}, {"input_a": None, "input_b": None}, {"active_inputs": 0}, {"active_inputs": 3}):
with self.subTest(inputs=inputs), self.assertRaisesRegex(ValueError, "exactly one"):
self.node.check_lazy_status(**inputs)
def test_missing_selected_input_and_none_data_fail(self):
with self.assertRaisesRegex(ValueError, "has no input"):
self.node.check_lazy_status(input_b=None, active_inputs=1)
with self.assertRaisesRegex(ValueError, "returned no data"):
self.node.select(input_a=None)
def test_falsey_values_and_tensors_are_forwarded_by_identity(self):
for value in (False, 0, "", [], {}, torch.zeros(1, 4, 4, 3), torch.ones(1, 4, 4), {"samples": torch.zeros(1, 4, 8, 8)}, [[torch.zeros(1, 2, 3), {}]]):
with self.subTest(type=type(value)):
self.assertIs(self.node.select(input_a=value)[0], value)
self.assertIs(self.node.select(input_b=value)[0], value)
if __name__ == "__main__":
unittest.main()
+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()
+2
View File
@@ -3,6 +3,7 @@ import { api } from "../../scripts/api.js";
import { ImageCompareView } from "./compare.js";
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";
@@ -55,6 +56,7 @@ app.registerExtension({
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);
+22
View File
@@ -96,3 +96,25 @@
.dd-comparer .dd-compare-captions { position: absolute; top: 8px; left: 8px; right: 8px; display: flex; justify-content: space-between; pointer-events: none; }
.dd-comparer .dd-compare-captions span { background: #171d27cc; color: #f1e3cf; padding: 3px 7px; border-radius: 3px; font-size: 11px; }
@container (max-width: 380px) { .dd-inpaint .dd-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px 12px; } }
.dd-group-presets { border: 0; border-radius: 4px; container-type: inline-size; contain: size layout; }
.dd-gp-toolbar, .dd-gp-form { display: flex; align-items: center; gap: 7px; padding: 9px 10px; flex: 0 0 auto; }
.dd-gp-toolbar { justify-content: space-between; border-bottom: 1px solid var(--dd-line); }
.dd-gp-toolbar strong { font-size: 12px; font-weight: 500; }
.dd-group-presets button { min-height: 26px; padding: 4px 7px; font-size: 11px; }
.dd-gp-form input { flex: 1; min-width: 0; color: var(--dd-text); background: var(--dd-panel); border: 1px solid var(--dd-line); padding: 5px 6px; font: inherit; border-radius: 3px; }
.dd-gp-body { display: grid; grid-template-columns: minmax(130px, 40%) minmax(0, 1fr); flex: 1; min-height: 0; }
.dd-gp-presets { border-right: 1px solid var(--dd-line); padding: 7px; display: flex; flex-direction: column; gap: 5px; }
.dd-gp-presets button { text-align: left; flex: 0 0 auto; white-space: normal; overflow-wrap: anywhere; }
.dd-gp-detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.dd-gp-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px; }
.dd-gp-detail-head strong { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-gp-groups { flex: 1; min-height: 0; padding: 0 8px; }
.dd-gp-scroll { overflow-y: auto; overscroll-behavior: contain; }
.dd-gp-group { display: flex; align-items: center; gap: 7px; padding: 7px 0; border-bottom: 1px solid #41495566; }
.dd-gp-color { flex: 0 0 4px; width: 4px; height: 20px; border-radius: 2px; }
.dd-gp-group-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-gp-group select { max-width: 118px; color: var(--dd-text); background: var(--dd-panel); border: 1px solid var(--dd-line); border-radius: 3px; padding: 4px; font: inherit; font-size: 11px; }
.dd-gp-changed .dd-gp-group-name { color: var(--dd-accent); }
.dd-gp-footer { display: flex; flex-wrap: wrap; gap: 5px; padding: 9px 8px; }
.dd-gp-status { flex: 0 0 auto; padding: 5px 10px 7px; color: var(--dd-muted); font-size: 10px; border-top: 1px solid var(--dd-line); }
.dd-gp-empty { color: var(--dd-muted); padding: 10px 4px; font-size: 11px; }
+22 -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.
}
@@ -269,6 +274,22 @@ export class InlineMaskEditor {
}
}
async replaceSource(url, name = "") {
const version = ++this.loadVersion;
const image = await loadImage(url);
if (version !== this.loadVersion || this.destroyed) return;
if (image.width !== this.mask.width || image.height !== this.mask.height) {
throw new Error(T("The result size must match the source image to preserve the mask"));
}
// Replacing only the source preserves mask pixels, the base snapshot,
// command history, redo history, active strokes and the current view.
this.flush();
this.image = image;
this.filename.textContent = name || T("Embedded image");
this.filename.title = this.filename.textContent;
this.updateButtons(); this.schedule();
}
importMask(ctx, image) {
const scratch = makeCanvas();
scratch.width = this.mask.width; scratch.height = this.mask.height;
+44
View File
@@ -0,0 +1,44 @@
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;
}
app.registerExtension({
name: "AdachiWorkbench.ExclusiveSwitch",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "AdachiExclusiveSwitch") return;
const created = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = created?.apply(this, arguments);
const widget = this.widgets?.find(widget => widget.name === "active_inputs");
if (widget) {
widget.type = "hidden";
widget.hidden = true;
widget.options ??= {};
widget.options.hidden = true;
widget.computeSize = () => [0, -4];
widget.serializeValue = () => Number(branchIsEnabled(this, "input_a"))
| (Number(branchIsEnabled(this, "input_b")) << 1);
}
const internalSlot = this.inputs?.findIndex(input => input.name === "active_inputs");
if (internalSlot >= 0) this.removeInput(internalSlot);
this.setSize([230, 80]);
return result;
};
},
});
+105
View File
@@ -0,0 +1,105 @@
/* Serializable group presets; no ComfyUI or third-party extension dependency. */
export const GROUP_PRESETS_TYPE = "AdachiGroupPresets";
const ACTIVE = 0, BYPASS = 4;
const uuid = () => globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
function groupKey(group, persist = false) {
if (group.id != null && group.id !== -1) return `group:${group.id}`;
if (persist) { group.flags ??= {}; group.flags.adachi_preset_id ??= uuid(); }
return group.flags?.adachi_preset_id ? `legacy:${group.flags.adachi_preset_id}` : `unsaved:${group.title}:${Array.from(group.pos || []).join(",")}`;
}
function members(group, seen = new Set()) {
if (seen.has(group)) return [];
seen.add(group); group.recomputeInsideNodes?.();
const result = new Set();
for (const item of group._children || group._nodes || group.nodes || []) {
if (typeof item.recomputeInsideNodes === "function") {
for (const node of members(item, seen)) result.add(node);
} else if (item.mode != null && item.type !== GROUP_PRESETS_TYPE) result.add(item);
}
return [...result];
}
export class GroupPresetState {
constructor(node, change) { this.node = node; this.change = change; this.restore(); }
restore() {
const raw = this.node.properties.adachi_group_presets;
const ids = new Set();
const presets = (Array.isArray(raw?.presets) ? raw.presets : []).filter(p => p && typeof p.id === "string" && !ids.has(p.id) && ids.add(p.id)).map(p => ({
id: p.id, name: String(p.name || "Preset"),
groups: Object.fromEntries(Object.entries(p.groups || {}).filter(([, state]) => state && [ACTIVE, BYPASS].includes(state.mode)).map(([key, state]) => [key, { mode: state.mode, title: String(state.title || key) }])),
}));
this.node.properties.adachi_group_presets = { version: 1, activeId: presets.some(p => p.id === raw?.activeId) ? raw.activeId : presets[0]?.id || null, presets };
}
get data() { return this.node.properties.adachi_group_presets; }
get active() { return this.data.presets.find(p => p.id === this.data.activeId) || null; }
groups(persist = false) {
return Array.from(this.node.graph?.groups || this.node.graph?._groups || []).map(group => {
const nodes = members(group);
const modes = new Set(nodes.map(node => node.mode));
return { key: groupKey(group, persist), title: String(group.title || "Group"), color: group.color || "#64748b", group, nodes, mode: modes.size === 1 ? [...modes][0] : nodes.length ? -1 : ACTIVE,
area: Math.abs((group.size?.[0] || 1) * (group.size?.[1] || 1)) };
});
}
capture() {
return Object.fromEntries(this.groups(true).map(group => [group.key, { title: group.title, mode: group.nodes.some(node => node.mode === ACTIVE) || !group.nodes.length ? ACTIVE : BYPASS }]));
}
add(name) {
const title = String(name).trim(); if (!title) return null;
let created;
this.change(() => {
created = { id: uuid(), name: title, groups: this.capture() };
this.data.presets.push(created); this.data.activeId = created.id;
});
return created;
}
rename(id, name) {
const preset = this.data.presets.find(p => p.id === id), title = String(name).trim();
if (preset && title) this.change(() => { preset.name = title; });
}
saveCurrent() { if (this.active) this.change(() => { this.active.groups = this.capture(); }); }
remove(id) {
this.change(() => {
this.data.presets = this.data.presets.filter(p => p.id !== id);
if (this.data.activeId === id) { this.data.activeId = this.data.presets[0]?.id || null; if (this.active) this.applyStates(this.active); }
});
}
setGroup(presetId, key, mode) {
const preset = this.data.presets.find(p => p.id === presetId), group = this.groups().find(g => g.key === key);
if (!preset || !group) return;
this.change(() => {
if (mode == null) delete preset.groups[key];
else {
const stableKey = groupKey(group.group, true);
if (stableKey !== key) delete preset.groups[key];
preset.groups[stableKey] = { title: group.title, mode: mode === BYPASS ? BYPASS : ACTIVE };
}
if (this.data.activeId === presetId) this.applyStates(preset);
});
}
apply(id) {
const preset = this.data.presets.find(p => p.id === id); if (!preset) return null;
let result;
this.change(() => { this.data.activeId = id; result = this.applyStates(preset); });
return result;
}
planStates(preset, available = this.groups()) {
const groups = available.filter(g => preset.groups[g.key]).sort((a, b) => b.area - a.area || a.key.localeCompare(b.key));
const plan = new Map(), overlaps = new Set();
// Smaller (more specific) groups override enclosing/overlapping groups.
for (const group of groups) {
const mode = preset.groups[group.key].mode;
for (const node of group.nodes) {
if (plan.has(node) && plan.get(node) !== mode) overlaps.add(node);
plan.set(node, mode);
}
}
return { plan, stats: { groups: groups.length, nodes: plan.size, overlaps: overlaps.size, missing: Object.keys(preset.groups).length - groups.length } };
}
applyStates(preset) {
const { plan, stats } = this.planStates(preset);
for (const [node, mode] of plan) node.mode = mode;
return stats;
}
}
+140
View File
@@ -0,0 +1,140 @@
import { app } from "../../scripts/app.js";
import { T } from "./i18n.js";
import { GROUP_PRESETS_TYPE, GroupPresetState } from "./group_preset_state.js";
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text) e.textContent = text; return e; };
function ensureStyle() {
if (document.getElementById("adachi-inpaint-style")) return;
const link = el("link"); link.id = "adachi-inpaint-style"; link.rel = "stylesheet";
link.href = new URL("./editor.css", import.meta.url).href; document.head.append(link);
}
class GroupPresetsPanel {
constructor(node) {
this.node = node; this.root = el("div", "dd-inpaint dd-group-presets");
this.model = new GroupPresetState(node, operation => {
app.canvas?.emitBeforeChange?.();
try { operation(); node.graph?.setDirtyCanvas(true, true); }
finally { app.canvas?.emitAfterChange?.(); }
this.refresh(true);
});
const toolbar = el("div", "dd-gp-toolbar"); toolbar.append(el("strong", "", T("Group presets")));
this.add = this.button(toolbar, T("+ New preset"), () => this.showForm("add"));
this.form = el("form", "dd-gp-form"); this.form.hidden = true;
this.nameInput = el("input"); this.nameInput.type = "text"; this.nameInput.maxLength = 120;
this.nameInput.setAttribute("aria-label", T("Preset name"));
this.form.append(this.nameInput);
const save = this.button(this.form, T("Save"), () => this.submitName()); save.classList.add("dd-primary");
this.button(this.form, T("Cancel"), () => { this.form.hidden = true; });
this.form.onsubmit = event => { event.preventDefault(); this.submitName(); };
this.nameInput.onkeydown = event => { event.stopPropagation(); if (event.key === "Escape") { event.preventDefault(); this.form.hidden = true; } };
const body = el("div", "dd-gp-body");
this.presets = el("nav", "dd-gp-presets dd-gp-scroll"); this.presets.setAttribute("aria-label", T("Presets"));
const detail = el("section", "dd-gp-detail");
const head = el("div", "dd-gp-detail-head"); this.title = el("strong"); head.append(this.title);
this.rename = this.button(head, T("Rename"), () => this.showForm("rename"));
this.groups = el("div", "dd-gp-groups dd-gp-scroll"); this.groups.setAttribute("aria-label", T("Group states"));
const footer = el("div", "dd-gp-footer");
this.capture = this.button(footer, T("Save current group states"), () => this.model.saveCurrent());
this.remove = this.button(footer, T("Delete preset"), () => { if (this.model.active) this.model.remove(this.model.active.id); });
detail.append(head, this.groups, footer); body.append(this.presets, detail);
this.status = el("div", "dd-gp-status"); this.status.setAttribute("role", "status");
this.root.append(toolbar, this.form, body, this.status);
this.onWheel = event => {
if (!this.root.contains(event.target)) return;
const scroll = event.target.closest(".dd-gp-scroll");
if (scroll && ((event.deltaY < 0 && scroll.scrollTop > 0) || (event.deltaY > 0 && scroll.scrollTop + scroll.clientHeight < scroll.scrollHeight - 1))) {
event.stopPropagation(); return;
}
event.preventDefault(); event.stopPropagation(); app.canvas?.processMouseWheel(event);
};
window.addEventListener("wheel", this.onWheel, { capture: true, passive: false });
this.timer = setInterval(() => { if (this.root.isConnected && !document.hidden) this.refresh(); }, 500);
this.refresh(true);
}
button(parent, label, callback) { const button = el("button", "", label); button.type = "button"; button.onclick = event => { event.stopPropagation(); callback(); }; parent.append(button); return button; }
showForm(mode) {
this.formMode = mode; this.form.hidden = false;
this.nameInput.value = mode === "rename" ? this.model.active?.name || "" : `${T("Preset")} ${this.model.data.presets.length + 1}`;
this.nameInput.focus({ preventScroll: true }); this.nameInput.select();
}
submitName() {
const name = this.nameInput.value.trim(); if (!name) { this.nameInput.focus(); return; }
if (this.formMode === "rename" && this.model.active) this.model.rename(this.model.active.id, name);
else this.model.add(name);
this.form.hidden = true;
}
apply(id) {
this.model.apply(id);
}
refresh(force = false) {
const active = this.model.active, groups = this.model.groups();
const signature = JSON.stringify([this.model.data, groups.map(g => [g.key, g.title, g.color, g.nodes.map(n => [n.id, n.mode])]), T("Group presets")]);
if (!force && signature === this.signature) return;
this.signature = signature;
const navScroll = this.presets.scrollTop, groupScroll = this.groups.scrollTop;
this.presets.replaceChildren(); this.groups.replaceChildren();
for (const preset of this.model.data.presets) {
const button = this.button(this.presets, preset.name, () => this.apply(preset.id));
button.dataset.presetId = preset.id; button.title = preset.name;
button.classList.toggle("dd-active", preset.id === active?.id);
button.setAttribute("aria-pressed", String(preset.id === active?.id));
}
if (!this.model.data.presets.length) this.presets.append(el("div", "dd-gp-empty", T("Add a preset to get started")));
this.title.textContent = active?.name || T("Group states"); this.title.title = this.title.textContent;
const expected = active ? this.model.planStates(active, groups) : { plan: new Map(), stats: {} };
for (const group of groups) {
const row = el("label", "dd-gp-group"); row.dataset.groupKey = group.key;
const dot = el("span", "dd-gp-color"); dot.style.background = group.color;
const label = el("span", "dd-gp-group-name", group.title); label.title = `${group.title} · ${group.key}`;
const select = el("select"); select.setAttribute("aria-label", group.title); select.disabled = !active;
for (const [value, name] of [["", "Keep current"], ["0", "Enabled"], ["4", "Bypassed"]]) {
const option = el("option", "", T(name)); option.value = value; select.append(option);
}
const saved = active?.groups[group.key]; select.value = saved ? String(saved.mode) : "";
if (saved && group.nodes.some(n => n.mode !== expected.plan.get(n))) { row.classList.add("dd-gp-changed"); label.title += ` · ${T("Current group state differs from the preset")}`; }
select.onchange = () => { if (active) this.model.setGroup(active.id, group.key, select.value === "" ? null : Number(select.value)); };
row.append(dot, label, select); this.groups.append(row);
}
if (!groups.length) this.groups.append(el("div", "dd-gp-empty", T("No groups in this workflow yet")));
for (const button of [this.rename, this.capture, this.remove]) button.disabled = !active;
const missing = active ? Object.keys(active.groups).filter(key => !groups.some(g => g.key === key)).length : 0;
this.status.textContent = `${this.model.data.presets.length} ${T("presets")} · ${groups.length} ${T("groups")}${missing ? ` · ${missing} ${T("missing groups")}` : ""}${expected.stats.overlaps ? ` · ${T("Overlapping groups use the smaller group's setting")}` : ""}`;
this.presets.scrollTop = navScroll; this.groups.scrollTop = groupScroll;
}
destroy() { clearInterval(this.timer); window.removeEventListener("wheel", this.onWheel, true); this.root.remove(); }
}
app.registerExtension({
name: "Adachi.GroupPresets",
registerCustomNodes() {
ensureStyle();
class GroupPresetsNode extends LGraphNode {
constructor() {
super(T("Group Bypass Presets"));
this.isVirtualNode = true; this.comfyClass = GROUP_PRESETS_TYPE;
this.serialize_widgets = false;
this.properties ??= {};
this.adachiGroupPresets = new GroupPresetsPanel(this);
const widget = this.addDOMWidget("group_presets", "adachi_group_presets", this.adachiGroupPresets.root, {
serialize: false, hideOnZoom: false, getMinHeight: () => 290, getMaxHeight: () => 1600,
});
widget.serialize = false; this.setSize([620, 410]);
}
onAdded() { this.adachiGroupPresets.refresh(true); }
onConfigure() { this.adachiGroupPresets.model.restore(); this.adachiGroupPresets.refresh(true); }
onRemoved() { this.adachiGroupPresets.destroy(); }
getExtraMenuOptions(_, options) {
const panel = this.adachiGroupPresets;
options.push(null, { content: T("Apply preset"), has_submenu: true, disabled: !panel.model.data.presets.length,
submenu: { options: panel.model.data.presets.map(p => ({ content: `${p.id === panel.model.data.activeId ? "✓ " : ""}${p.name}`, callback: () => panel.apply(p.id) })) } },
{ content: T("+ New preset"), callback: () => panel.showForm("add") },
{ content: T("Save current group states"), disabled: !panel.model.active, callback: () => panel.model.saveCurrent() });
}
}
GroupPresetsNode.title = T("Group Bypass Presets");
GroupPresetsNode.desc = T("Switch saved group enable/bypass presets; no connections required.");
LiteGraph.registerNodeType(GROUP_PRESETS_TYPE, GroupPresetsNode);
GroupPresetsNode.category = "Adachi-Inpaint-Workbench";
},
});
+27 -1
View File
@@ -6,6 +6,30 @@
// buttons are drawn by this plugin, so they translate here by reading Comfy.Locale.
const ZH = {
"Group Bypass Presets": "组忽略预设 / Group Bypass Presets",
"Switch saved group enable/bypass presets; no connections required.": "切换已保存的分组启用/忽略预设,无需接线。",
"Group presets": "分组预设",
"+ New preset": "+ 添加预设",
"Preset name": "预设名称",
"Save": "保存",
"Cancel": "取消",
"Presets": "预设列表",
"Group states": "分组状态",
"Rename": "改名",
"Save current group states": "记住当前分组状态",
"Delete preset": "删除预设",
"Preset": "预设",
"Apply preset": "应用预设",
"Add a preset to get started": "添加第一套预设",
"No groups in this workflow yet": "当前工作流还没有分组",
"Keep current": "保持当前",
"Enabled": "打开",
"Bypassed": "忽略",
"Current group state differs from the preset": "当前分组状态与预设不同",
"Overlapping groups use the smaller group's setting": "重叠分组以较小分组的设置为准",
"presets": "套预设",
"groups": "个分组",
"missing groups": "个分组已不存在",
// compare.js
"Result": "结果",
"Choose result": "选择重绘结果",
@@ -71,7 +95,9 @@ const ZH = {
// inline_inpaint.js
"The inline mask node was removed": "内联蒙版节点已移除",
"Adopted the result as the new source image; the old mask was cleared": "已采用重绘结果作为新原图,旧蒙版已清空",
"Adopted the result as the new source image; the mask and undo history were preserved": "已采用重绘结果作为新原图,蒙版与撤销记录已保留",
"The result size must match the source image to preserve the mask": "为保留蒙版,重绘结果尺寸必须与原图一致",
"Wait for the source image to finish loading before adopting a result": "请等待原图加载完成后再采用结果",
"Drag to resize canvas height": "上下拖动调整画布高度",
"Resize canvas height": "调整画布高度",
"Upload failed": "上传失败",
+24 -22
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();
});
@@ -91,7 +89,8 @@ app.registerExtension({
onDirty() { markChanged(); },
onImage() { if (!restoring || !node.properties.adachi_compact_ui) fitNodeToImage(); },
onGraphWheel: event => app.canvas?.processMouseWheel(event),
async onUpload(file) {
async onUpload(file, { preserveMask = false } = {}) {
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the source image to finish loading before adopting a result"));
const version = ++uploadVersion;
const body = new FormData();
body.append("image", file, file.name);
@@ -102,12 +101,13 @@ app.registerExtension({
const uploaded = await response.json();
if (version !== uploadVersion || editor.destroyed) return;
const name = uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
imageWidget.value = name;
maskWidget.value = "";
sourceWidget.value = "";
restoring = false;
await editor.setImage(imageUrl(name), "", file.name);
if (editor.embed.checked) sourceWidget.value = editor.sourceData();
if (preserveMask && editor.ready) await editor.replaceSource(imageUrl(name), file.name);
else await editor.setImage(imageUrl(name), "", file.name);
if (version !== uploadVersion || editor.destroyed) return;
imageWidget.value = name;
maskWidget.value = preserveMask ? editor.flush() : "";
sourceWidget.value = editor.embed.checked ? editor.sourceData() : "";
markChanged();
},
onEmbed(enabled) {
@@ -115,13 +115,14 @@ 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 => {
if (editor.destroyed) throw new Error(T("The inline mask node was removed"));
await editor.options.onUpload(file);
await editor.options.onUpload(file, { preserveMask: true });
editor.view = "overlay"; editor.updateButtons(); editor.schedule();
editor.status(T("Adopted the result as the new source image; the old mask was cleared"));
editor.status(T("Adopted the result as the new source image; the mask and undo history were preserved"));
};
const domWidget = node.addDOMWidget("adachi_canvas", "adachi_inline_mask", editor.root, {
serialize: false,
@@ -130,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";
}