Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f822bb3382 | ||
|
|
51e8a94f14 | ||
|
|
af3de6eafb | ||
|
|
9a38504ea7 | ||
|
|
a9e2b7c168 | ||
|
|
d5cb95d2e1 | ||
|
|
95b09ee484 | ||
|
|
75d42c44d1 | ||
|
|
5fcb150384 | ||
|
|
fdc70ede61 |
@@ -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
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## 0.5.10
|
||||
|
||||
- Add a pin toggle beside Fit in Inline Mask: keep the image fitted, route plain wheel to the workflow, retain Ctrl+wheel brush sizing and save the toggle with the workflow.
|
||||
- Shorten the inline load button to Upload (上传).
|
||||
- Bound inline preview bitmap allocation to source detail and an 8 Mi-pixel budget; cache the source/mask composite during cursor movement. Full-resolution mask data remains unchanged.
|
||||
- Successful adoption resets comparison history to the accepted image as 1/1. The next generation starts a fresh result list; failed adoption preserves history.
|
||||
|
||||
## 0.5.9
|
||||
|
||||
- Bound comparison bitmap resolution to source detail, 8192 pixels per edge and a 16 Mi-pixel allocation budget, avoiding quadratic bitmap growth at large workflow zoom.
|
||||
- Redraw only the strip swept by the comparison divider during pointer movement; redraw fully after image, size or hover changes.
|
||||
- Draw the divider as a separate overlay so it remains one screen pixel wide even when magnifying the workflow.
|
||||
|
||||
## 0.5.8
|
||||
|
||||
- Count each comparison preview file once, even when cached execution notifications replay it; keep the selected result on replay.
|
||||
- Remove duplicate preview entries when reloading older saved comparison histories.
|
||||
|
||||
## 0.5.7
|
||||
|
||||
- Accumulate comparison results across runs, select the newest result and preserve each result's matching original, including after workflow reload.
|
||||
- Keep comparison images permanently fitted and centered in the canvas; remove independent wheel zoom and middle/Space image panning.
|
||||
- Route wheel events over the full comparison node, including the picture, to ComfyUI workflow navigation.
|
||||
- Preserve hover comparison, the full-result view on pointer leave, batch selection and adoption with mask/history retention.
|
||||
|
||||
## 0.5.6
|
||||
|
||||
- Follow KJNodes Get/Set routes and reroutes when deciding which exclusive input is enabled, including the setter's and real source node's bypass/mute states.
|
||||
- Support virtual routers using the current resolveVirtualOutput API, and safely reject routing cycles or missing setters.
|
||||
- Exclude inactive inputs from detached execution DTOs before frontend link resolution, avoiding missing-input errors when a virtual Get itself is bypassed; retain all editable/saved workflow wires.
|
||||
- Add dependency-free routing regression checks to CI and validate actual KJ Get/Set workflows with real CPU queue output pixels in both supported frontend versions/renderers.
|
||||
|
||||
## 0.5.5
|
||||
|
||||
- Bind the inline canvas, inpaint parameter panel and comparison widget width directly to the node's logical graph width. Cached screen-space measurements cannot shrink the widgets a second time at reduced graph zoom.
|
||||
- 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.
|
||||
|
||||
@@ -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 the image editing area and small **Upload** / **Fit** / pin 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.
|
||||
@@ -95,6 +95,8 @@ Very large images still use browser memory, and PNG encoding time depends on res
|
||||
|
||||
## Painting tools and inpainting parameters
|
||||
|
||||
The Inline Mask header has **Upload**, **Fit** and a pin toggle (**Keep** / **保持**). Enable the pin to keep the picture fitted and centered: plain wheel navigates the workflow, Ctrl+wheel still adjusts the brush, and image panning is disabled. Disable the pin to resume image zoom and pan. The pin state is saved with the workflow. Display buffers are bounded during workflow zoom; the saved mask retains its original resolution.
|
||||
|
||||
Once Inline Mask is connected to the `image` input of **WebUI Inpaint**, the original painting controls appear in that inpaint node, and the parameter panel below drives the node's real sampling parameters.
|
||||
Width and height each get their own row with both a slider and a number field; denoise is a slider too, and **Use source size** fills in the source dimensions, rounded to the nearest multiple of 8 the sampler requires.
|
||||
Mask mode, fill, sampling area, sampler, scheduler, steps, CFG and seed are shown directly; mask processing, batch and Refiner switching live under **Mask, batch and advanced settings**.
|
||||
@@ -104,11 +106,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.
|
||||
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.
|
||||
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 **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. Successful adoption clears the old list and displays the accepted image as 1/1; the next generation starts a fresh list using the new source. Failed adoption leaves the old list intact. Saving the workflow also saves preview references, selection and the pending new-round state.
|
||||
|
||||
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 +157,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 +191,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.
|
||||
|
||||
@@ -207,9 +214,11 @@ python -m unittest discover -s tests -v
|
||||
想用中文界面时,把 ComfyUI 的语言切成中文即可,插件会自动跟随:
|
||||
在 ComfyUI 设置里找到 **Locale / 语言**(设置项 `Comfy.Locale`)选择 `zh`,然后刷新浏览器(`Ctrl+F5`)。
|
||||
节点名称、分类、输入输出名与提示由 `locales/zh/` 提供,通过 ComfyUI 的 `/i18n` 机制加载;
|
||||
画布按钮与参数面板由 `web/i18n.js` 读取同一个语言设置翻译,因此「宽度」「高度」「载入原图」等也会一起变回中文。
|
||||
画布按钮与参数面板由 `web/i18n.js` 读取同一个语言设置翻译,因此「宽度」「高度」「上传」等也会一起变回中文。
|
||||
英文用户看到的一直是英文(英文是内置默认值);某个词条若没有对应翻译,会自动回退为英文。
|
||||
两套语言共用同一个节点类 ID 和参数键名,所以已保存的工作流不受语言切换影响。
|
||||
|
||||
新增「自动二选一」:把两个正面条件(或图像、模型等同类型数据)接到输入 A/B,输出接共同下游;忽略其中一个分支,就自动使用另一个。两路都启用或都无输入会报错,不需要 switch。切换节点放在两个被控制分组之外,也能配合组忽略预设使用。
|
||||
|
||||
|
||||
Anima 兼容:支持其 Wan/Qwen VAE 的单帧五维潜空间,也保留普通四维 VAE;会同步适配蒙版、填充、批次和解码回贴。普通 Anima 图生图请保持“专用重绘模型”关闭,并使用配套模型、VAE 与文本条件;这不是多帧视频重绘功能。
|
||||
|
||||
+13
-3
@@ -1,9 +1,19 @@
|
||||
# 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.10**. 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.10` | Pin inline fit, bound/cache inline previews, Upload label and restart comparison rounds after adoption |
|
||||
| `v0.5.9` / `local-before-v0.5.10` | Bound comparison bitmap allocations and redraw only the swept divider strip |
|
||||
| `v0.5.8` / `local-before-v0.5.9` | Deduplicate replayed comparison previews and repair duplicate saved history |
|
||||
| `v0.5.7` / `local-before-v0.5.8` | Fixed comparison images; wheel navigates the workflow; retain result history across runs |
|
||||
| `v0.5.6` / `local-before-v0.5.7` | Exclusive selector follows Get/Set and virtual routes correctly |
|
||||
| `v0.5.5` / `local-before-v0.5.6` | Bind canvas/panel widths to node geometry and retain vertical resizing |
|
||||
| `v0.5.4` / `local-before-v0.5.5` | Isolate Fit clicks and refresh editor assets on upgrade |
|
||||
| `v0.5.3` / `local-before-v0.5.4` | Fit beside Load image in the inline mask, plus the 5D VAE fix |
|
||||
| `v0.5.2` / `local-before-v0.5.3` | Single-frame 5D VAE support for image models, plus all previous features |
|
||||
| `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 +22,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.10
|
||||
```
|
||||
|
||||
Return to this version with:
|
||||
|
||||
```powershell
|
||||
git switch --detach v0.5.1
|
||||
git switch --detach v0.5.10
|
||||
```
|
||||
|
||||
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
|
||||
|
||||
@@ -31,6 +31,29 @@ 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.
|
||||
- 0.5.9: on frontend 1.52.7 in classic nodes and Nodes 2.0, a 1024x1536 comparison was measured at workflow zoom 1x, 2x and 4x. At 4x, 24 synchronous paints with readback dropped from 483 to 36 ms (classic) and 453 to 35 ms (Nodes 2.0). These are isolated browser measurements, not end-to-end workflow FPS guarantees. The divider measured one screen pixel at every zoom. Dirty-strip redraws matched full-redraw pixels exactly across forward/reverse/unchanged divider movements and edge positions; pointer exit hides the divider.
|
||||
- 0.5.10: frontends 1.42.11 and 1.52.7, both classic nodes and Nodes 2.0, verify the Upload label and pin icon beside Fit, pin persistence across workflow reload, plain wheel navigating the workflow, Ctrl+wheel brush sizing while pinned, and restored image wheel zoom after unpinning. At 4x graph zoom the display allocation remains bounded, the original mask dimensions remain 320x200, and 20 cursor-only redraws never recomposite the mask overlay. All 15 standalone editor regression groups pass, including real painting, Alt/right-button erase, undo/redo, shapes, pressure, serialization and zoom/pan coordinate mapping.
|
||||
- 0.5.10: actual adoption reduces the comparison list to 1/1, preserves mask PNG and undo/redo identities, and saves the pending new-round state. Reloading before generating retains that state; the next two-image batches start at 2/2 and then accumulate to 4/4. Rejected adoption leaves history unchanged. These checks pass in both frontend versions/renderers.
|
||||
|
||||
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
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "adachi-inpaint-workbench"
|
||||
version = "0.5.1"
|
||||
version = "0.5.10"
|
||||
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]
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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()
|
||||
+47
-51
@@ -1,5 +1,4 @@
|
||||
/* Original hover-comparison UI, MIT licensed. */
|
||||
import { registerImageWheel } from "./wheel_gestures.js";
|
||||
import { T } from "./i18n.js";
|
||||
const element = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
@@ -19,7 +18,7 @@ export class ImageCompareView {
|
||||
this.options = options;
|
||||
this.root = element("div", "dd-inpaint dd-comparer");
|
||||
this.originals = []; this.results = []; this.index = 0;
|
||||
this.zoom = 1; this.pan = { x: 0, y: 0 }; this.split = .5;
|
||||
this.split = .5;
|
||||
this.version = 0; this.ready = false;
|
||||
this.dimensionsLabel = element("span", "dd-dimensions");
|
||||
const toolbar = element("div", "dd-row dd-compare-toolbar");
|
||||
@@ -29,14 +28,17 @@ export class ImageCompareView {
|
||||
toolbar.append(this.resultSelect, this.dimensionsLabel);
|
||||
const spacer = element("span", "dd-spacer"); toolbar.append(spacer);
|
||||
const fit = element("button", "", T("Fit")); fit.type = "button";
|
||||
fit.onclick = () => { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }; toolbar.append(fit);
|
||||
fit.onclick = event => { event.preventDefault(); event.stopPropagation(); this.schedule(); }; toolbar.append(fit);
|
||||
this.stage = element("div", "dd-stage dd-compare-stage");
|
||||
this.canvas = document.createElement("canvas"); this.canvas.tabIndex = 0;
|
||||
this.canvas.setAttribute("aria-label", T("Result left, original right; hover to compare"));
|
||||
this.canvas.oncontextmenu = event => event.preventDefault();
|
||||
this.canvas.addEventListener("pointerenter", event => { this.hovered = true; this.move(event); this.schedule(); });
|
||||
this.canvas.addEventListener("pointerleave", () => { this.hovered = false; this.schedule(); });
|
||||
this.stage.append(this.canvas);
|
||||
this.divider = element("div");
|
||||
Object.assign(this.divider.style, { position: "absolute", background: "#ffffffb3", pointerEvents: "none" });
|
||||
this.divider.hidden = true;
|
||||
this.stage.append(this.canvas, this.divider);
|
||||
this.empty = element("div", "dd-empty", T("Connect an original image and a result, then run the workflow")); this.stage.append(this.empty);
|
||||
const adoption = element("div", "dd-row dd-adoption");
|
||||
adoption.append(element("span", "dd-compare-label", T("Adopt into")));
|
||||
@@ -46,34 +48,22 @@ export class ImageCompareView {
|
||||
adoption.append(this.targetSelect);
|
||||
this.adoptButton = element("button", "dd-primary", T("Adopt this result")); this.adoptButton.type = "button";
|
||||
this.adoptButton.onclick = () => this.adopt(); adoption.append(this.adoptButton);
|
||||
this.message = element("div", "dd-status", T("Move to compare · wheel to zoom · middle-drag to pan"));
|
||||
this.message = element("div", "dd-status", T("Result left, original right; hover to compare"));
|
||||
this.message.hidden = true; this.message.setAttribute("role", "alert");
|
||||
this.root.append(this.stage, toolbar, adoption, this.message);
|
||||
this.canvas.addEventListener("pointermove", event => this.move(event));
|
||||
this.canvas.addEventListener("pointerdown", event => {
|
||||
if (!this.ready) return;
|
||||
if (!this.ready || event.button !== 0) return;
|
||||
event.preventDefault(); event.stopPropagation(); this.canvas.focus({ preventScroll: true });
|
||||
if (event.button === 1 || this.space) {
|
||||
this.drag = { id: event.pointerId, x: event.clientX, y: event.clientY, pan: { ...this.pan } };
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
} else this.move(event);
|
||||
this.move(event);
|
||||
});
|
||||
const finish = event => {
|
||||
if (!this.drag || this.drag.id !== event.pointerId) return;
|
||||
const id = this.drag.id; this.drag = null;
|
||||
if (this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id);
|
||||
};
|
||||
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) this.canvas.addEventListener(name, finish);
|
||||
this.stopWheelCapture = registerImageWheel(this.canvas, () => this.ready, event => this.wheel(event), options.onGraphWheel);
|
||||
this.canvas.addEventListener("wheel", event => this.wheel(event), { passive: false });
|
||||
this.canvas.addEventListener("keydown", event => {
|
||||
if (event.code === "Space") { event.preventDefault(); event.stopPropagation(); this.space = true; }
|
||||
else if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
this.split = Math.max(0, Math.min(1, this.split + (event.key === "ArrowRight" ? .02 : -.02))); this.schedule();
|
||||
}
|
||||
});
|
||||
this.canvas.addEventListener("keyup", event => { if (event.code === "Space") this.space = false; });
|
||||
this.canvas.addEventListener("blur", () => { this.space = false; });
|
||||
this.observer = new ResizeObserver(() => this.schedule()); this.observer.observe(this.stage);
|
||||
this.refreshTargets(); this.updateButton();
|
||||
}
|
||||
@@ -140,36 +130,21 @@ export class ImageCompareView {
|
||||
|
||||
transform() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
if (this.viewport?.width && this.viewport?.height && rect.width && rect.height) {
|
||||
this.pan.x *= rect.width / this.viewport.width;
|
||||
this.pan.y *= rect.height / this.viewport.height;
|
||||
}
|
||||
this.viewport = { width: rect.width, height: rect.height };
|
||||
const scale = this.before ? Math.min(rect.width / this.before.width, rect.height / this.before.height) * this.zoom : 1;
|
||||
return { width: rect.width, height: rect.height, scale, x: (rect.width - (this.before?.width || 0) * scale) / 2 + this.pan.x, y: (rect.height - (this.before?.height || 0) * scale) / 2 + this.pan.y };
|
||||
const scale = this.before ? Math.min(rect.width / this.before.width, rect.height / this.before.height) : 1;
|
||||
return { width: rect.width, height: rect.height, scale, x: (rect.width - (this.before?.width || 0) * scale) / 2, y: (rect.height - (this.before?.height || 0) * scale) / 2 };
|
||||
}
|
||||
|
||||
position(event) { const rect = this.canvas.getBoundingClientRect(), t = this.transform(); return { x: (event.clientX - rect.left - t.x) / t.scale, y: (event.clientY - rect.top - t.y) / t.scale }; }
|
||||
|
||||
wheel(event) {
|
||||
if (!this.ready) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const before = this.position(event), rect = this.canvas.getBoundingClientRect();
|
||||
this.zoom = Math.max(.1, Math.min(32, this.zoom * Math.exp(-event.deltaY * .0015)));
|
||||
const t = this.transform();
|
||||
this.pan.x += event.clientX - rect.left - (t.x + before.x * t.scale);
|
||||
this.pan.y += event.clientY - rect.top - (t.y + before.y * t.scale);
|
||||
this.schedule();
|
||||
this.options.onGraphWheel?.(event);
|
||||
}
|
||||
|
||||
move(event) {
|
||||
if (!this.ready) return;
|
||||
this.hovered = true;
|
||||
if (this.drag) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
this.pan.x = this.drag.pan.x + event.clientX - this.drag.x;
|
||||
this.pan.y = this.drag.pan.y + event.clientY - this.drag.y;
|
||||
} else this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
|
||||
this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
@@ -178,26 +153,47 @@ export class ImageCompareView {
|
||||
render() {
|
||||
const t = this.transform(), ratio = Math.min(devicePixelRatio || 1, 2);
|
||||
if (!t.width || !t.height) return;
|
||||
const width = Math.round(t.width * ratio), height = Math.round(t.height * ratio);
|
||||
// Graph zoom changes the screen rect, not the amount of source detail.
|
||||
// Never allocate a huge upscaled bitmap just to magnify existing pixels.
|
||||
const rasterRatio = Math.min(ratio, this.ready ? 1 / t.scale : ratio,
|
||||
8192 / t.width, 8192 / t.height, Math.sqrt(16 * 1024 * 1024 / (t.width * t.height)));
|
||||
const width = Math.max(1, Math.round(t.width * rasterRatio)), height = Math.max(1, Math.round(t.height * rasterRatio));
|
||||
if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; }
|
||||
const ctx = this.canvas.getContext("2d");
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, width, height);
|
||||
if (!this.ready) return;
|
||||
ctx.setTransform(t.scale * ratio, 0, 0, t.scale * ratio, t.x * ratio, t.y * ratio);
|
||||
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
|
||||
if (!this.hovered) return;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this.divider.hidden = !this.ready || !this.hovered;
|
||||
if (!this.ready) { ctx.clearRect(0, 0, width, height); this.lastPaint = null; return; }
|
||||
const split = this.split * this.before.width;
|
||||
const lineX = Math.round((t.x + split * t.scale) * rasterRatio) + .5;
|
||||
if (this.hovered) {
|
||||
const screenScale = t.width / this.canvas.clientWidth || 1;
|
||||
Object.assign(this.divider.style, {
|
||||
left: `${(t.x + split * t.scale) / screenScale}px`, top: `${t.y / screenScale}px`,
|
||||
height: `${this.before.height * t.scale / screenScale}px`, width: `${1 / screenScale}px`,
|
||||
});
|
||||
}
|
||||
const key = [this.version, width, height, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio].join(":");
|
||||
const previous = this.lastPaint;
|
||||
ctx.save();
|
||||
// While hovering, pixels outside the old/new divider positions are
|
||||
// unchanged. Clip the redraw to that strip rather than repainting all.
|
||||
if (previous?.key === key && previous.hovered && this.hovered) {
|
||||
const left = Math.max(0, Math.floor(Math.min(previous.lineX, lineX)) - 2);
|
||||
const right = Math.min(width, Math.ceil(Math.max(previous.lineX, lineX)) + 2);
|
||||
ctx.beginPath(); ctx.rect(left, 0, right - left, height); ctx.clip();
|
||||
}
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.setTransform(t.scale * rasterRatio, 0, 0, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio);
|
||||
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
|
||||
this.lastPaint = { key, hovered: this.hovered, lineX };
|
||||
if (!this.hovered) { ctx.restore(); return; }
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(split, 0, this.before.width - split, this.before.height); ctx.clip();
|
||||
ctx.drawImage(this.before, 0, 0); ctx.restore();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
const lineX = Math.round((t.x + split * t.scale) * ratio) + .5;
|
||||
ctx.beginPath(); ctx.moveTo(lineX, t.y * ratio); ctx.lineTo(lineX, (t.y + this.before.height * t.scale) * ratio);
|
||||
ctx.strokeStyle = "#ffffffb3"; ctx.lineWidth = 1; ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true; ++this.version; cancelAnimationFrame(this.frame); this.observer.disconnect();
|
||||
this.stopWheelCapture();
|
||||
this.before = this.after = null; this.canvas.width = this.canvas.height = 0; this.root.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+28
-5
@@ -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.9";
|
||||
import { appendComparison } from "./compare_history.js?v=0.5.8";
|
||||
import { routeWheelOutsideImage } from "./wheel_routing.js";
|
||||
import { T } from "./i18n.js";
|
||||
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
|
||||
|
||||
function viewUrl(image) {
|
||||
const type = ["input", "output", "temp"].includes(image?.type) ? image.type : "temp";
|
||||
@@ -47,26 +49,47 @@ app.registerExtension({
|
||||
const blob = await response.blob();
|
||||
if (blob.size > 64 * 1024 * 1024) throw new Error(T("Result image exceeds 64 MiB; downscale it first"));
|
||||
await target.adachiUseResult(new File([blob], `adachi-result-${Date.now()}.png`, { type: "image/png" }));
|
||||
// Adoption starts a new round. Keep the accepted image visible
|
||||
// as 1/1, then replace it with the next round's first outputs.
|
||||
const data = { originals: [image], results: [image] };
|
||||
node.properties.adachi_compare = data;
|
||||
node.properties.adachi_result_index = 0;
|
||||
node.properties.adachi_compare_new_round = true;
|
||||
await compare.setData(data, 0);
|
||||
app.graph?.change?.();
|
||||
},
|
||||
});
|
||||
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 = node.properties.adachi_compare_new_round
|
||||
? appendComparison() : 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_compare_new_round = false;
|
||||
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;
|
||||
|
||||
+52
-19
@@ -24,6 +24,8 @@ export class InlineMaskEditor {
|
||||
this.mask = makeCanvas();
|
||||
this.baseMask = makeCanvas();
|
||||
this.overlay = makeCanvas();
|
||||
this.scene = makeCanvas();
|
||||
this.maskRevision = 0;
|
||||
this.tool = "brush";
|
||||
this.view = "overlay";
|
||||
this.brushSize = 64;
|
||||
@@ -92,7 +94,7 @@ export class InlineMaskEditor {
|
||||
head.append(this.sizeLabel);
|
||||
this.root.append(head);
|
||||
const files = el("div", "dd-row dd-files");
|
||||
this.loadButton = this.button(files, T("Load image"), T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
|
||||
this.loadButton = this.button(files, T("Upload"), T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
|
||||
this.filename = el("span", "dd-filename", T("Drop an image onto the canvas"));
|
||||
files.append(this.filename);
|
||||
this.root.append(files);
|
||||
@@ -170,7 +172,13 @@ 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());
|
||||
this.keepButton = this.button(footer, "", T("Keep"), event => { event.stopPropagation(); this.setKeepFit(!this.keepFit); });
|
||||
this.keepButton.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-4 1-4 4 1 4-3 3-3-6-6-3 3-3 4 1 4-4zM8 16l-6 6"/></svg>';
|
||||
// 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 +197,8 @@ 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);
|
||||
this.fitButton.after(this.keepButton);
|
||||
// These are the original live controls; the sampler mounts this
|
||||
// same DOM tree so brush state and callbacks are preserved.
|
||||
}
|
||||
@@ -310,6 +320,8 @@ export class InlineMaskEditor {
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
this.keepButton?.classList.toggle("dd-active", !!this.keepFit);
|
||||
this.keepButton?.setAttribute("aria-pressed", String(!!this.keepFit));
|
||||
this.stage?.classList.toggle("dd-erasing", !!(this.altErase || this.tool === "erase" || this.stroke?.type === "erase"));
|
||||
for (const [name, button] of Object.entries(this.toolButtons || {})) {
|
||||
const active = name === (this.altErase ? "erase" : this.tool);
|
||||
@@ -326,6 +338,7 @@ export class InlineMaskEditor {
|
||||
|
||||
dimensions() {
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
if (this.keepFit) { this.zoom = 1; this.pan = { x: 0, y: 0 }; }
|
||||
if (this.viewport?.width && this.viewport?.height && r.width && r.height) {
|
||||
this.pan.x *= r.width / this.viewport.width;
|
||||
this.pan.y *= r.height / this.viewport.height;
|
||||
@@ -343,6 +356,7 @@ export class InlineMaskEditor {
|
||||
|
||||
pointerDown(event) {
|
||||
if (!this.ready || this.pointerId != null) return;
|
||||
if (this.keepFit && (this.space || (!event.altKey && this.tool === "pan") || event.button === 1)) return;
|
||||
event.preventDefault(); event.stopPropagation(); this.root.focus({ preventScroll: true });
|
||||
this.altErase = !!event.altKey; this.updateButtons();
|
||||
this.pointerId = event.pointerId;
|
||||
@@ -410,6 +424,7 @@ export class InlineMaskEditor {
|
||||
}
|
||||
|
||||
paintSegment(ctx, command, a, b) {
|
||||
if (ctx.canvas === this.mask) this.maskRevision++;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = command.type === "erase" ? "destination-out" : "source-over";
|
||||
ctx.globalAlpha = command.opacity / 100;
|
||||
@@ -478,6 +493,7 @@ export class InlineMaskEditor {
|
||||
redo() { if (this.future.length && !this.stroke) { this.history.push(this.future.pop()); this.replay(); } }
|
||||
|
||||
changed() {
|
||||
this.maskRevision++;
|
||||
this.dirty = true;
|
||||
this.flush();
|
||||
this.schedule();
|
||||
@@ -520,6 +536,13 @@ export class InlineMaskEditor {
|
||||
|
||||
fit() { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }
|
||||
|
||||
setKeepFit(enabled, notify = true) {
|
||||
this.keepFit = !!enabled;
|
||||
if (this.keepFit) this.fit();
|
||||
this.updateButtons(); this.schedule();
|
||||
if (notify) this.options.onKeepFit?.(this.keepFit);
|
||||
}
|
||||
|
||||
setBrushSize(value) {
|
||||
this.brushSizeInput.value = Math.max(1, Math.min(512, Math.round(value)));
|
||||
this.brushSizeInput.dispatchEvent(new Event("input"));
|
||||
@@ -528,6 +551,7 @@ export class InlineMaskEditor {
|
||||
wheel(event) {
|
||||
if (!this.ready) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (this.keepFit && !event.ctrlKey) { this.options.onGraphWheel?.(event); return; }
|
||||
if (event.ctrlKey) {
|
||||
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1);
|
||||
if (delta) {
|
||||
@@ -564,24 +588,33 @@ export class InlineMaskEditor {
|
||||
render() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
const ratio = Math.min(devicePixelRatio || 1, 2);
|
||||
const w = Math.round(rect.width * ratio), h = Math.round(rect.height * ratio);
|
||||
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; this.overlay.width = w; this.overlay.height = h; }
|
||||
const ctx = this.canvas.getContext("2d");
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h);
|
||||
if (!this.ready) return;
|
||||
const d = this.dimensions();
|
||||
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
|
||||
if (this.view !== "mask") ctx.drawImage(this.image, 0, 0);
|
||||
else { ctx.fillStyle = "black"; ctx.fillRect(0, 0, this.image.width, this.image.height); }
|
||||
if (this.view !== "image") {
|
||||
const overlay = this.overlay.getContext("2d");
|
||||
overlay.setTransform(1, 0, 0, 1, 0, 0); overlay.clearRect(0, 0, w, h);
|
||||
overlay.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
|
||||
overlay.drawImage(this.mask, 0, 0);
|
||||
if (this.view === "overlay") { overlay.globalCompositeOperation = "source-in"; overlay.fillStyle = "#ffad45"; overlay.fillRect(0, 0, this.mask.width, this.mask.height); overlay.globalCompositeOperation = "source-over"; }
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = this.view === "mask" ? 1 : this.overlayOpacity / 100; ctx.drawImage(this.overlay, 0, 0); ctx.globalAlpha = 1;
|
||||
const ratio = Math.min(devicePixelRatio || 1, 2, this.ready ? 1 / d.scale : 2,
|
||||
8192 / rect.width, 8192 / rect.height, Math.sqrt(8 * 1024 * 1024 / (rect.width * rect.height)));
|
||||
const w = Math.max(1, Math.round(rect.width * ratio)), h = Math.max(1, Math.round(rect.height * ratio));
|
||||
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; this.overlay.width = w; this.overlay.height = h; }
|
||||
const display = this.canvas.getContext("2d");
|
||||
if (!this.ready) { display.clearRect(0, 0, w, h); this.sceneKey = null; return; }
|
||||
const key = [this.loadVersion, this.maskRevision, w, h, d.scale * ratio, d.x * ratio, d.y * ratio, this.view, this.overlayOpacity].join(":");
|
||||
if (key !== this.sceneKey) {
|
||||
if (this.scene.width !== w || this.scene.height !== h) { this.scene.width = w; this.scene.height = h; }
|
||||
const ctx = this.scene.getContext("2d");
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h);
|
||||
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
|
||||
if (this.view !== "mask") ctx.drawImage(this.image, 0, 0);
|
||||
else { ctx.fillStyle = "black"; ctx.fillRect(0, 0, this.image.width, this.image.height); }
|
||||
if (this.view !== "image") {
|
||||
const overlay = this.overlay.getContext("2d");
|
||||
overlay.setTransform(1, 0, 0, 1, 0, 0); overlay.clearRect(0, 0, w, h);
|
||||
overlay.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
|
||||
overlay.drawImage(this.mask, 0, 0);
|
||||
if (this.view === "overlay") { overlay.globalCompositeOperation = "source-in"; overlay.fillStyle = "#ffad45"; overlay.fillRect(0, 0, this.mask.width, this.mask.height); overlay.globalCompositeOperation = "source-over"; }
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = this.view === "mask" ? 1 : this.overlayOpacity / 100; ctx.drawImage(this.overlay, 0, 0); ctx.globalAlpha = 1;
|
||||
}
|
||||
this.sceneKey = key;
|
||||
}
|
||||
const ctx = display;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h); ctx.drawImage(this.scene, 0, 0);
|
||||
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
|
||||
if (this.stroke && ["rect", "lasso"].includes(this.stroke.type)) {
|
||||
ctx.save(); ctx.strokeStyle = "#ffcf85"; ctx.lineWidth = 1.5 / d.scale; ctx.setLineDash([5 / d.scale, 4 / d.scale]);
|
||||
@@ -608,7 +641,7 @@ export class InlineMaskEditor {
|
||||
window.removeEventListener("keydown", this.modifierDown, true);
|
||||
window.removeEventListener("keyup", this.modifierUp, true);
|
||||
window.removeEventListener("blur", this.modifierUp);
|
||||
for (const canvas of [this.mask, this.baseMask, this.overlay, this.canvas]) canvas.width = canvas.height = 0;
|
||||
for (const canvas of [this.mask, this.baseMask, this.overlay, this.scene, this.canvas]) canvas.width = canvas.height = 0;
|
||||
this.history = []; this.future = []; this.image = null; this.controls?.remove(); this.root.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -86,6 +86,8 @@ const ZH = {
|
||||
"Image": "原图",
|
||||
"Mask": "蒙版",
|
||||
"Fit": "适应",
|
||||
"Keep": "保持",
|
||||
"Upload": "上传",
|
||||
"Fit the image to the canvas": "适应画布",
|
||||
"File is too large (max 64 MB)": "图片文件不能超过 64 MB",
|
||||
"Loading image…": "正在载入原图…",
|
||||
|
||||
+16
-14
@@ -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.10";
|
||||
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();
|
||||
});
|
||||
@@ -89,6 +87,7 @@ app.registerExtension({
|
||||
compact: true,
|
||||
onChange(data) { if (maskWidget && !restoring) maskWidget.value = data; },
|
||||
onDirty() { markChanged(); },
|
||||
onKeepFit(enabled) { node.properties.adachi_keep_fit = enabled; markChanged(); },
|
||||
onImage() { if (!restoring || !node.properties.adachi_compact_ui) fitNodeToImage(); },
|
||||
onGraphWheel: event => app.canvas?.processMouseWheel(event),
|
||||
async onUpload(file, { preserveMask = false } = {}) {
|
||||
@@ -117,6 +116,7 @@ app.registerExtension({
|
||||
markChanged();
|
||||
},
|
||||
});
|
||||
editor.root.dataset.adachiVersion = "0.5.10";
|
||||
node.adachiEditor = editor;
|
||||
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
|
||||
node.adachiUseResult = async file => {
|
||||
@@ -132,6 +132,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");
|
||||
@@ -192,6 +193,7 @@ app.registerExtension({
|
||||
const previousConfigure = node.onConfigure;
|
||||
node.onConfigure = function () {
|
||||
const configured = previousConfigure?.apply(this, arguments);
|
||||
editor.setKeepFit(!!node.properties.adachi_keep_fit, false);
|
||||
cancelAnimationFrame(restoreFrame); restoreFrame = requestAnimationFrame(restore);
|
||||
return configured;
|
||||
};
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user