Compare commits

..
78 Commits
Author SHA1 Message Date
adachi 71baccdacd Release version 2.1.1 2026-11-12 22:45:29 +08:00
adachi 6ace9fc662 Streamline workbench controls 2026-11-12 22:35:30 +08:00
adachi fe26998c24 Release color painting and workbench reliability improvements 2026-11-12 22:15:23 +08:00
adachi 87c06a0fd9 Improve workspace comparison and controls 2026-11-12 21:38:33 +08:00
adachi 1343922c0f Document compatibility and adopt GPL-3.0-only 2026-11-12 21:18:07 +08:00
Adachi 4d0fbe7a62 Release stability improvements as version 2.0.1 2026-11-10 12:19:25 +08:00
Adachi f8531c7399 Fix cancellation, workspace lifetime races and dense-mask memory use 2026-11-10 11:48:54 +08:00
Adachi 93995804b7 Use Node.js 22 for frontend regression tests 2026-11-09 20:57:58 +08:00
Adachi a23e6c733c Release Adachi Workbench 2.0 with localized UI and a unified workflow 2026-11-09 20:55:20 +08:00
Adachi 630f9e0cd1 Document generic workbench routing and workflow compatibility 2026-11-09 18:05:19 +08:00
Adachi 47fa6cc3e9 Verify generic workflow discovery and processing-chain preservation 2026-11-09 18:05:19 +08:00
Adachi c80ef8f551 Adapt workbench to native workflow connections and resizable panes 2026-11-09 18:05:19 +08:00
Adachi 02719c3a8d Document editable generation and model dependency verification 2026-11-09 16:33:51 +08:00
Adachi 8d56704285 Diagnose missing model components and expose CLIP and VAE choices 2026-11-09 16:33:51 +08:00
Adachi bc080ec1d9 Keep canvas editable and report live workflow stage progress 2026-11-09 16:33:51 +08:00
Adachi 8ec528161c Prevent adopting stale previews while recovering new results 2026-11-09 15:17:03 +08:00
Adachi f1d88c8e64 Document workspace controls and cloud recovery verification 2026-11-09 15:09:45 +08:00
Adachi e908e5a8bb Recover missing comparison results and stream image loading progress 2026-11-09 15:09:45 +08:00
Adachi 4a6f44787d Add workspace themes and compact controls with clear crop docks 2026-11-09 15:09:44 +08:00
Adachi 4cb1be78ff Follow sampler conditioning and enabled group presets 2026-11-09 15:09:44 +08:00
Adachi 1465bd8d7e Detect missing workspace backend before queue submission 2026-11-09 15:09:44 +08:00
Adachi 392149ffa6 Move workspace launcher onto inpaint node and extend canvas height 2026-11-09 10:55:47 +08:00
Adachi 7c9c854846 Rename inpaint node to Adachi Inpaint Workbench 2026-11-09 10:44:45 +08:00
Adachi 08d2153d3b Add white workflow inpainting workspace with validation and source history 2026-11-09 10:20:37 +08:00
Adachi 75e18a2558 Keep group preset details collapsible 2026-11-09 10:19:03 +08:00
Adachi 880dfb518d Retry comparison previews and add reload control 2026-11-05 21:40:27 +08:00
Adachi 0935b37b92 Prepare stable 1.0.0 release 2026-10-28 11:39:24 +08:00
Adachi f6e42474c0 Keep comparison previews in memory and show upload progress 2026-10-28 11:17:31 +08:00
Adachi 87fadd7197 Refresh replacement uploads and sync inpaint dimensions 2026-10-28 10:54:59 +08:00
Adachi 0b89b063d6 Expire comparison previews and download latest result 2026-10-28 10:44:33 +08:00
adachi 3030531275 Remove the ✓ merge step; right-click reselects directly
Adoption returns to the repaint mask (option A) and layer blending stays opt-in via ◐. Right-click/Escape now flattens any remaining layer and releases the region in one action, so the separate ✓ commit button is gone.
2026-10-17 14:44:16 +08:00
adachi f76b5aa0fd Fix the inline status hint staying visible
The earlier status() rework let non-error hints stay visible, so "Orange is the
painted mask" lingered on the canvas. Restore the simple rule that only errors
stay visible, keep an explicit notice() for the one case that must persist, and
bump the editor asset version so browsers fetch the corrected module.
2026-10-17 14:27:42 +08:00
adachi 175a063195 Restructure crop adoption and speed up change detection
Crop & Blend adoption now bakes the layer into the source at full quality and
returns to the repaint mask immediately, so the user can generate again right
away. Layer blending is entered explicitly through the blend button instead of
auto-entering after adoption.

The inline mask change fingerprint now uses the source file's size and mtime
instead of hashing its full high-resolution bytes, removing a full-file read on
every change/execution.
2026-10-17 14:08:54 +08:00
adachi da4a26c982 Reuse the decoded result and report silent adoption skips
Adopting a crop result previously downloaded the full-size image a second time
inside the crop layer. Pass the already-decoded image through, cutting one
decode round trip.

Adoption could also return without any feedback when the preview was still
loading, no target was selected, or the crop/layer state changed. Those cases now
show a visible message instead of failing silently.
2026-10-17 13:57:03 +08:00
adachi a5bd9624b8 Preserve the mask when uploading a new source image
Uploading previously reset the mask, history and viewport. It now goes through the
same replaceSource path that adopting a result uses, so only the source pixels
change and the painted mask, undo/redo history and current view survive.

If the new image has a different size the mask cannot be kept, so the upload falls
back to a fresh load and shows a notice instead of failing.
2026-10-17 13:34:00 +08:00
adachi 0f178a55f6 Add a download button and make the pin freeze the current view
The inline mask gains a download button beside upload that saves the source image
as a PNG at its own resolution.

The pin toggle previously forced the image back to a fitted view on every
measurement, so it could not hold a deliberately panned or zoomed position. It now
freezes whatever position is currently drawn, keeps panning and zooming available,
and still routes the plain wheel to the workflow canvas.
2026-10-17 13:30:26 +08:00
adachi 67d372e481 Drop locale snapshots from example 05
Example 05 carried localized_name snapshots for its slots, so every reader saw
the author's language instead of their own. Remove them, as already done for the
other examples.
2026-10-17 00:33:41 +08:00
adachi 7f432d921c Place the Clear button after the swap control
In Crop & Blend the bottom bar now reads preset, swap, clear, mask, blend and
merge. In Mask mode the other bar controls are hidden, so Clear remains the only
button and stays at the bottom left. Bump the inline crop asset version to fetch
the updated file.
2026-10-17 00:23:55 +08:00
adachi c406ef10d9 Add a Clear button to the bottom-left canvas bar
One click empties the mask of the active target in both Mask and Crop & Blend
modes, reusing the existing clear command so the paint bounds still apply. The
button is disabled until an image is loaded. Also bump the inline crop module's
asset version so the changed file is fetched instead of a cached copy.
2026-10-17 00:20:49 +08:00
adachi 02b3ce2662 Remove hover text from the painting canvas
Hovering the image showed a hint in every mode. Drop the canvas title entirely
so no hover text appears over the picture, keep the instructions on the bottom
controls and their button tooltips, and delete the now-unused hint entries from
the locale dictionary.
2026-10-17 00:11:29 +08:00
adachi f5883dce8a Move selected-region painting help to the repaint mask button
The crop canvas repeated the selected-region painting help while a region was
locked. Move that text onto the repaint mask button, drop the now-unused
dictionary entry, and leave the locked canvas without a repeated hover hint.
2026-10-17 00:07:15 +08:00
adachi 5283ac99d0 Preview original crop while holding right mouse and add hover help 2026-10-16 23:51:46 +08:00
adachi aae35150c4 Use right-click for crop reselection and move Undo into inline toolbar 2026-10-16 23:38:27 +08:00
adachi 38fd6813f7 Allow changing crop dimensions after locking the region 2026-10-16 23:23:42 +08:00
adachi 07ce762159 Adopt crop results through comparison and sync crop resolution 2026-10-16 23:18:25 +08:00
adachi b668e9ace0 Prevent oversized sources from disabling workflow drafts at startup 2026-10-16 23:13:09 +08:00
adachi 53bebffb83 Fix draft storage and fold controls alongside revised crop workflow 2026-10-16 22:43:22 +08:00
adachi 24be45d996 Merge crop and layer blending into inline editor with selection tools 2026-10-16 22:08:37 +08:00
adachi 9f208f61d7 Rewrite English and Chinese READMEs with feature screenshots 2026-10-16 17:09:08 +08:00
adachi f1dbaf0fb0 Restore dropped exclusive inputs from subgraph output routes 2026-10-16 15:09:46 +08:00
adachi 3c225ea242 Resolve Get Set bypass states before validation and cache lookup 2026-10-16 14:48:44 +08:00
adachi 8f128f5417 Resolve exclusive inputs from submitted prompt connections 2026-10-16 14:33:42 +08:00
adachi f822bb3382 Pin inline fit and reset comparison rounds after adoption 2026-10-16 14:10:06 +08:00
adachi 51e8a94f14 Reduce comparison redraw cost at large workflow zoom 2026-10-16 13:52:29 +08:00
adachi af3de6eafb Deduplicate replayed comparison previews 2026-10-16 13:44:26 +08:00
adachi 9a38504ea7 Keep comparison fitted and retain results across runs 2026-10-16 13:29:43 +08:00
adachi a9e2b7c168 Follow Get Set routes when selecting the enabled input 2026-10-16 13:14:04 +08:00
adachi d5cb95d2e1 Bind editor and panel widget widths to node geometry 2026-10-16 12:16:21 +08:00
adachi 95b09ee484 Isolate Fit button clicks and version inline editor assets 2026-10-16 12:03:13 +08:00
adachi 75d42c44d1 Place Fit beside Load image in the inline mask 2026-10-16 11:30:11 +08:00
adachi 5fcb150384 Support single-frame 5D VAE latents for Anima and other image models 2026-10-16 11:27:13 +08:00
adachi fdc70ede61 Set the Comfy Registry publisher id
The publisher account adachi0130 exists on the registry, so fill in
tool.comfy.PublisherId to enable publishing this node pack there.
2026-10-16 10:54:27 +08:00
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
adachi f31e15ec9f Release 0.4.2 with English defaults and a zh locale
Bump the version and record the change: user-facing strings are English by
default and node class IDs, parameter identifiers and enum values are
unchanged. locales/en and locales/zh make the node pack follow the ComfyUI
language setting, so a Chinese ComfyUI shows Chinese labels while English
stays the built-in default.
2026-10-15 19:46:21 +08:00
adachi 340fc54b49 Document that the node pack follows the ComfyUI language setting 2026-10-15 19:33:42 +08:00
adachi 3664cbf1d8 Follow the ComfyUI language for the plugin's own canvas and panel labels
ComfyUI's locales/ feature only covers node definitions, so the custom painting
controls and parameter panel are translated here instead, by reading Comfy.Locale.

English stays the built-in default, which keeps the Manager listing usable for
the general audience. When the ComfyUI language is Chinese the canvas buttons and
the parameter panel labels switch to Chinese as well; any key without a
translation falls back to English.
2026-10-15 19:33:10 +08:00
adachi c132d1517b Add zh locale so the nodes can display Chinese again
English stays the source of truth in code so the ComfyUI Manager listing is
usable for the general audience; Chinese returns through ComfyUI's official
locale feature, where locales/<lang>/nodeDefs.json and main.json are merged
into the /i18n response (see app/custom_node_manager.py).

Set the ComfyUI language to zh and hard-refresh to see the Chinese node names,
socket names and tooltips again; other languages keep the English defaults.
Node class IDs, parameter identifiers and enum values are unchanged, so
existing workflows are unaffected by switching language.
2026-10-15 19:19:01 +08:00
Adachi c49cae39e2 Make user-facing strings English for ComfyUI Manager listing
Node display names, category, descriptions, tooltips and all frontend labels are
now English so the nodes are usable for the general Manager audience. Node class
IDs, parameter identifiers and enum values are unchanged, so existing workflows
stay compatible. Also documents how this pack differs from ComfyUI's built-in
modal mask editor and Painter node.
2026-10-15 19:07:41 +08:00
adachi cf70590406 Remove informal tone suffix from user-facing text and docs 2026-10-15 17:41:59 +08:00
adachi a84b897d76 Refresh the README demo image 2026-10-15 17:40:34 +08:00
Adachi 40e05092d7 Document pending ComfyUI Manager listing submission 2026-10-15 17:22:00 +08:00
Adachi 3f2fc7e033 Prepare public Adachi-Inpaint-Workbench release and distinct eraser cursor 2026-10-15 17:17:04 +08:00
Codex 9a60d9e174 Move editor controls to inpaint node and minimize image node chrome 2026-10-15 16:47:45 +08:00
Codex b678071646 Add inline inpaint controls and refine comparison and wheel behavior 2026-10-15 16:26:19 +08:00
115 changed files with 10807 additions and 1840 deletions
+3
View File
@@ -0,0 +1,3 @@
.github/
tests/
registry/
+45
View File
@@ -0,0 +1,45 @@
name: Validate plugin
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
cpu-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: '3.12'
- uses: actions/setup-node@v7.1.0
with:
node-version: '22'
- name: Install CPU test dependencies
run: |
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
python -m pip install numpy Pillow
- name: Run backend regression tests
run: python -m unittest discover -s tests -v
- name: Check frontend syntax and metadata
run: |
python - <<'PY'
import pathlib, subprocess, tomllib
config = tomllib.loads(pathlib.Path('pyproject.toml').read_text(encoding='utf-8'))
assert config['project']['name'] == 'adachi-inpaint-workbench'
assert config['tool']['comfy']['DisplayName'] == 'Adachi-Inpaint-Workbench'
for source in pathlib.Path('web').glob('*.js'):
subprocess.run(['node', '--input-type=module', '--check'], input=source.read_bytes(), check=True)
PY
- name: Run all frontend regression tests
run: |
python - <<'PY'
import pathlib, subprocess
for test in sorted(pathlib.Path('tests').glob('test_*.mjs')):
print(f'Running {test}', flush=True)
subprocess.run(['node', '--experimental-default-type=module', str(test)], check=True)
PY
+29
View File
@@ -0,0 +1,29 @@
name: Publish to Comfy Registry
on:
workflow_dispatch:
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- name: Verify publisher configuration
env:
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
run: |
python - <<'PY'
import os, pathlib, tomllib
config = tomllib.loads(pathlib.Path('pyproject.toml').read_text(encoding='utf-8'))
if not config['tool']['comfy']['PublisherId'].strip():
raise SystemExit('Set tool.comfy.PublisherId to your registered publisher ID before publishing.')
if not os.environ.get('REGISTRY_ACCESS_TOKEN'):
raise SystemExit('Add your Registry API key as the REGISTRY_ACCESS_TOKEN repository secret.')
PY
- name: Publish custom node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+281
View File
@@ -0,0 +1,281 @@
# Changelog
## 2.1.1 · 2026-11-12
- Remove the duplicate lower Fit button in the standalone workspace; the top Fit control and the workflow comparison node's button remain available.
- Move the whole-image and masked-area choices directly below Generate in the workspace.
- Place color-paint controls in the existing lower toolbar for both editing modes.
## 2.1.0 · 2026-11-12
- Add an independent color-paint layer to Mask and Crop & Blend modes. Alt-click samples the visible image color, Ctrl + wheel changes brush size, and the painted image is used for inpainting.
- Show upload progress while merging an adopted crop before releasing the selection; keep the selection when the upload fails so it can be retried.
- Keep the editor, comparison and parameter controls in the workspace when ComfyUI remounts DOM widgets during layout changes.
- Show download and upload progress while adopting results; retry transient image-read failures after uploads and preserve the previous source on failure.
- Add wheel zoom, middle-button pan and Fit to the standalone workspace's sliding and before/after comparison views.
- Move Generate, width, height and denoise controls higher in the workspace settings, and allow positive and negative prompts to be collapsed.
- Adopt GPL-3.0-only for the current project and preserve the original MIT copyright and permission notice for earlier releases and applicable legacy material. Previous releases retain the permissions under which they were distributed.
- Update bilingual documentation with real workspace screenshots, a tested-model compatibility matrix, workflow limits and a concise release history.
## 2.0.1 · stability fixes (2026-11-10)
- Release late preview object URLs after sibling failures, discard corrupt cached PNGs, bound decoding and cancel stale preview reads. Downloads and both ordinary/crop adoption use bounded transfers too.
- Add upload inactivity detection and cancellation without limiting the overall duration of advancing XHR transfers; keep authentication fallback and real byte progress.
- Dispose workspaces safely during initialization/validation, coalesce background checks, and hand submitted task IDs to a reopened workspace before result recovery. Recovery selects sliding comparison even when the result was already cached.
- Share one exact-task history poller between node and workspace; cancel disposed recovery requests and release discarded pending states.
- Keep the last good source usable after an invalid replacement. Protect source rollback from intervening completed strokes/crop changes and release partially decoded masks and retired blend canvases.
- Bound preview-cache batch metadata to 512 batches as well as the existing byte limit. Empty/invalid batches leave cached images unchanged.
- Find dense mask bounds from row/column occupancy instead of allocating coordinates per painted pixel; sanitize into a private tensor with one less full-image allocation.
- Reject malformed workflow input mappings before core replacement hooks can crash. Existing node IDs, workflows, masks, crop layers, presets and sampling behavior are retained.
## 2.0.0 · 2026-11-09
- Ship the standalone Adachi Workbench with English default labels and Simplified Chinese translations following ComfyUI's language setting.
- Add one official text-to-image / inpainting workflow with group bypass presets and automatic exclusive routing, using neutral prompts and a user-selected checkpoint.
- Use a transparent title treatment with semi-transparent black lettering instead of a white title background.
- Rename the page brand to Adachi Workbench and add a remembered, keyboard-accessible canvas/settings divider.
- Remove personal-template node IDs and preset/model-name matching. Discover components from the native execution graph, including Use Everywhere and subgraphs.
- Preserve intermediate model, conditioning, image and output processing. Add explicit pipeline/controller selection for ambiguous workflows.
- Distinguish inherited LoRA chains from custom overrides; preserve split CLIP paths and allow execution-only custom LoRAs without a loader in the editable graph.
- Keep the canvas and editing controls available during generation; submit an independent snapshot and defer automatic view switching while a pointer stroke is active.
- Show actual execution stages and sampler steps above workflow settings, including queued, cached, reconnect and completion states.
- Return to mask editing after adopting a crop, preserving its region, result layer and repaint mask.
- List missing model components and their model folders, and expose separate text encoder / VAE choices for Anima-style profiles.
- Detect missing backend routes before submitting and support API-prefixed proxy paths.
- Add remembered light/dark themes, compact edit and before/after icons, hover help and default sliding comparison after generation.
- Follow sampler conditioning through Get/Set and exclusive routes; derive preset buttons from the enabled controller and warn when unavailable.
- Keep crop and result controls above the measured source-history dock on narrow screens.
- Recover lost result notifications by queued task ID in both node and workspace; add streamed image progress, inactivity timeouts, cancellation, automatic retries and reconnect recovery.
## 1.0.1
- Retry comparison image loading after network errors or timeouts and add a manual reload button. Expired preview references still require a new generation.
## 1.0.0
- First stable release of the inline inpainting workspace, comparison, crop blending and workflow switching tools.
- Includes in-memory comparisons and source upload progress from 0.9.0. Update the bilingual crop instructions and example to match the current editing controls.
## 0.9.0
- Keep new comparison originals and results in a bounded in-memory cache instead of creating preview files. Existing disk-backed preview references remain supported; adopted sources and layers remain persistent input files. The cache defaults to 512 MiB, adjustable with `ADACHI_COMPARE_MEMORY_MIB` (64–4096 MiB); restart or eviction expires old previews.
- Show actual browser upload progress for source images, followed by a loading indication. Authentication fallback uses an indeterminate indicator rather than inventing a percentage.
## 0.8.2
- Actually display a newly uploaded image when its dimensions differ; the incompatible old mask is reset. Same-size replacement continues to preserve the mask.
- Automatically fill connected inpaint dimensions from an uploaded image in Mask mode and from a locked crop in Crop & Blend mode. Existing saved workflow dimensions and linked parameter inputs are respected.
## 0.8.1
- Expire only this plugin's comparison preview PNGs after 24 hours, checking hourly even in a long-running ComfyUI. Adopted input images and unrelated temporary files are preserved. Cached comparisons refresh before the expiry deadline.
- Add a compact download button for the latest generated image, including the last image in a batch, independently of the history selection.
## 0.8.0
- Restructure adoption in Crop & Blend: after "Adopt this result" the layer is baked into the source at full quality and the editor returns to the repaint mask immediately, so you can generate again right away. The ◐ button now enters layer blending explicitly instead of auto-entering after adoption.
- Remove the ✓ merge step entirely: right-click (or Escape) now releases the selected region and flattens any remaining layer in one action, so there is no intermediate commit button between adopting a result and picking the next region.
- Speed up inline mask change detection: the source image is fingerprinted by size and mtime instead of hashing its full high-resolution bytes on every change/execution.
## 0.7.2
- Adopting a result no longer decodes the full-size image a second time: the already-decoded image is reused for the crop layer, cutting one download/decode round trip.
- Show a visible message when adoption is skipped because the preview is still loading, no target is selected, or the crop/layer state changed, instead of failing silently.
- Bump the asset versions for the crop, comparison and result-adoption modules.
## 0.7.1
- Uploading a new source image now keeps the painted mask, undo/redo history and viewport, the same way adopting a result does; only the source pixels change. A first upload still starts with an empty mask.
- When the new image has a different size the mask cannot be kept, so the image loads fresh and a notice explains that the mask was reset instead of failing the upload.
## 0.7.0
- Add a download button next to the inline upload button: it saves the current source image as a PNG at its own resolution. It stays disabled until an image is loaded.
- Change the pin toggle so it freezes the view exactly as it is drawn instead of re-fitting the image: panning and zooming stay available while pinned, and the pinned view survives canvas resizes.
- Keep routing the plain wheel to the workflow canvas while pinned, as before.
## 0.6.9
- Place the Clear button directly after the swap width/height control in Crop & Blend; in Mask mode it is the only visible button of that bar and stays at the bottom left of the canvas.
## 0.6.8
- Add a Clear button to the bottom-left canvas bar: one click empties the mask of the active target, in both Mask and Crop & Blend modes, and it stays disabled until an image is loaded.
- Move the selected-region painting help onto the repaint mask button, and remove hover text from the painting canvas entirely: hovering the image no longer shows any hint in any mode. The bottom controls and their button tooltips keep the instructions.
- Remove the now-unused canvas and repaint mask hint entries from the locale dictionary.
- Bump the inline crop module's asset version so the updated file is fetched instead of a cached copy.
## 0.6.7
- Before merging an adopted crop layer, hold the right mouse button to preview the original; release, pointer cancellation or window blur restores the workspace. Preview changes display only. After merge, right-click again releases the crop selection.
- Add English/Chinese hover instructions for bottom controls and state-specific canvas gestures, including size/hardness shortcuts, masks, adoption, merging and Undo.
## 0.6.6
- Right-click releases a locked crop without painting; the next plain click selects a new region. Adopted layer effects are merged before reselection. Right-click no longer paints or erases in either mode; Alt remains the temporary eraser.
- Remove the redundant reselect button. Move the existing mask Undo button from the inpaint controls to the inline toolbar, immediately after Merge, using a return-arrow icon. Undo also remains accessible in ordinary mask mode.
## 0.6.5
- Keep crop size presets, width/height swap and custom dimensions available after locking a region. Changing dimensions automatically unlocks it for a new selection.
- Merge an adopted layer before changing the region, and retain the old selection if merging fails.
## 0.6.4
- Use a pure black dashed crop outline and automatically synchronize connected inpaint resolution when locking a crop. Linked resolution inputs remain under their upstream node's control.
- Remove the Inline Mask result socket and obsolete feedback connections. Both modes now receive results exclusively through Image Compare adoption.
- Crop adoption keeps the full source, crop location and repaint mask; the accepted patch remains a separate editable blend layer. Generation alone does not update it. Tag comparison previews with their originating crop to reject stale adoption.
## 0.6.3
- Check oversized embedded sources before workflow configuration, so the first autosave cannot disable browser draft storage before the mode-change guard runs. Upload exact source bytes before changing the file reference, including sources in subgraph definitions.
- Keep the array and optional named widget snapshots synchronized when serializing Inline Mask; stale embedded copies no longer survive in the named snapshot. Keep repaint masks, fusion masks and layer references unchanged.
- Apply the source-capacity check after uploading/replacing an embedded image too. Respect disabled browser persistence and retain embedded data if upload fails.
- A page that already encountered the native draft quota error needs one reload to reset ComfyUI's disabled-storage flag: save the workflow with Ctrl+S first, then use Ctrl+F5. No browser drafts are cleared by the plugin.
## 0.6.2
- Remove magic wand and quick selection, including their toolbar menu and background workers.
- Locking a crop dims the outside and starts an empty repaint mask, ready for manual painting. Brush, shape and mask actions stay inside the locked crop.
- Reduce presets to six sizes with 64-aligned dimensions from 512 through 2048; retain the width/height swap button. Custom sizing steps by 64 pixels.
## 0.6.1
- Add a collapsed painting-tools/mask-settings section in WebUI Inpaint and Inpaint Prepare. Keep the complete Undo/Redo/Invert/Fill/Clear/Import/Export row visible, and save the fold state in the workflow.
- Reproduce browser draft quota failures with a large embedded source. Before a mode change or enabling embedding, probe available draft capacity using a temporary plugin-owned key; credit the existing current draft and preserve all other browser storage.
- If the embedded source does not fit, upload an exact PNG copy first, then use the input-file reference without reloading pixels or clearing repaint masks, fusion masks, layers or undo commands. A failed upload retains the original embedded data and mode. Small embeddings remain embedded.
- Share in-flight recovery across embedding/mode changes, respect disabled ComfyUI browser persistence, and keep the recovery notice above the crop toolbar.
## 0.6.0
- Merge rectangular cropping and two-layer blending into Inline Mask, with a compact mode selector; ordinary mask mode ignores the result input.
- Add 32-aligned crop presets, pointer-following selection, fixed-region repeat generation, exact full-image stitching and a third full_image output. Feedback wiring is split into an acyclic execution graph before validation.
- Replace the proposed crop version history with original/result layers and a separate editable fusion mask. Brush reveals the new layer; Alt erases back to the original. Reselecting merges visible layers first.
- Add a compact selection-tool menu: contiguous magic wand (W), brush-guided color quick selection (Q), tolerance and selection brush size. Alt subtracts, and both tools support undo/redo in either mode. Selection calculations run in a Web Worker.
- Use arrow/fit/pin icons, Ctrl+wheel for size and Shift+wheel for hardness. Use source size follows the locked crop in crop mode.
- Retain existing image/mask output indices, old workflows, comparison adoption and mask preservation. Restart ComfyUI and hard-refresh after updating.
## 0.5.13
- Restore a missing enabled execution input by resolving its saved workflow route through Get/Set, reroutes and subgraph output slots to an existing flattened execution node.
- Fix the captured remote failure where the selected positive branch existed in a tag subgraph but its input link was absent from the submitted exclusive node. Never invent missing backend nodes or connect a different node type.
- Include node ID, frontend/workflow flags and submitted-connection flags in selection errors to distinguish an ignored branch from lost execution wiring.
- Validate three actual failed remote request snapshots locally and two lightweight CPU queue probes with the same missing-link/subgraph mapping. Restart ComfyUI after this backend update.
## 0.5.12
- Trace enabled Get nodes back through their named Set and encoder in saved workflow metadata; ignored Sets disable their branch even when both Gets remain enabled and both execution links survive.
- Normalize exclusive branch flags and remove only ignored execution inputs before ComfyUI validation/cache lookup. Keep all original workflow wires and normal cache reuse.
- Repeat the workflow check during lazy selection, support scoped subgraph IDs and both serialized link formats, and retain raw-API behavior without workflow metadata.
- Validate the supplied remote workflow's routing states using lightweight real image queues, including reverse switching, cache reuse and strict invalid states. A full ComfyUI restart is required after updating.
## 0.5.11
- Resolve exclusive selection against the actual submitted prompt connections, overriding stale frontend flags when only one branch survives Get/Set, mute or bypass resolution.
- Support dynamically expanded subgraph node IDs through ComfyUI's dynamic prompt, while retaining explicit branch flags for bypassed transforms that keep both physical connections.
- Keep both-enabled/both-absent errors and include the received branch state in their diagnostic message. Restart ComfyUI after updating this backend change.
## 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.
- 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.
- Keep node class IDs, parameter identifiers and enum values unchanged, so existing workflows stay compatible.
- Add `locales/en` and `locales/zh` and follow the ComfyUI language setting, so a Chinese ComfyUI shows Chinese labels while English stays the built-in default. Any missing translation falls back to English.
- Document how this pack differs from ComfyUI's built-in modal mask editor and `Painter` node.
## 0.4.1
- Rename the public node pack to **Adachi-Inpaint-Workbench**; keep existing node class IDs compatible with saved workflows.
- Show an eraser cursor and a distinct dashed brush outline while Alt is held or the eraser tool is selected; restore the brush cursor on release.
- Add Comfy Registry metadata, dependency declarations, a Manager registration entry, CPU CI and a manual Registry publishing workflow.
## 0.4.0
- Move the original live editor controls and sampling controls into the WebUI Inpaint node.
- Keep the inline node focused on the image and a small upload button.
- Move comparison controls below the picture and remove the top toolbar band.
## 0.3.0
- Add WebUI-style sliders, separate width/height rows and a source-size button.
- Use result-left/original-right comparison and show the full result when the pointer leaves.
- Route toolbar wheel events to the workflow and keep image wheel gestures separate.
## 0.2.0
- Add temporary Alt erase, image comparison and result adoption.
+670 -17
View File
@@ -1,21 +1,674 @@
MIT License
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2026 Adachi Workbench contributors
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Adachi Workbench contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
# License history
The current project is licensed under GPL-3.0-only; see the root [LICENSE](../LICENSE).
Releases up to and including v2.0.1 were distributed under the MIT License. Their original copyright and permission notice is reproduced in [MIT-previous-releases.txt](MIT-previous-releases.txt), including for previously MIT-licensed material carried forward where applicable. Recipients of older versions retain the permissions granted with those versions. The retained notice does not grant an additional MIT license to new GPL-only changes.
+307 -128
View File
@@ -1,155 +1,334 @@
# 安达工作台 / Adachi Workbench
<div align="center">
直接在 ComfyUI 工作流节点里载入原图、涂抹蒙版、局部重绘,无需另开遮罩编辑器喵。
# Adachi-Inpaint-Workbench
## 安装
**Paint. Inpaint. Compare. Keep going.**
将整个 `ComfyUI-Adachi-Workbench` 文件夹复制到 `ComfyUI/custom_nodes/`,保留其中的 `web/`、`backend.py`、`__init__.py` 喵。
重启 ComfyUI,然后在浏览器按 `Ctrl+F5` 刷新前端缓存喵。
在节点搜索中输入 `Inline Mask` 或 `WebUI Inpaint`,分类是 **安达工作台 / Adachi Workbench** 喵。
An inline inpainting workspace for ComfyUI, with WebUI-style controls and tools for switching workflow branches.
Windows 也可以从解压后的插件文件夹运行安装脚本,将参数改为自己的、包含 `main.py` 的 ComfyUI 目录喵。
**English** · [简体中文](README.zh-CN.md)
[![License: GPL-3.0-only](https://img.shields.io/badge/License-GPL--3.0--only-38966b)](LICENSE)
[![ComfyUI custom nodes](https://img.shields.io/badge/ComfyUI-Custom_Nodes-4776b4)](https://github.com/Comfy-Org/ComfyUI)
[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-4776b4)](pyproject.toml)
[![Validate plugin](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml/badge.svg)](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml)
[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Node reference](#node-reference) · [Examples](#example-workflows)
![Inline painting, WebUI-style inpainting controls and image comparison in one ComfyUI workflow](preview/nodes2-inline.png)
*The original workbench preview. Earlier toolbar screenshots are retained below; v0.6 uses arrow / fit / pin icons.*
</div>
## Why this workbench?
Inpainting usually means moving between the workflow, a mask editor and the generated result. This pack keeps that loop on the graph: paint directly on a node, generate, compare, then adopt a result as your next source **without losing the painted mask**.
| Tool | What it adds |
| --- | --- |
| **Inline Mask** | Paint masks or crop, inpaint and blend two layers in the same node. |
| **Adachi Inpaint Workbench** | Whole-image or masked-area sampling, familiar inpainting controls and compositing back at the source resolution. |
| **Image Compare** | Hover comparison, result history and one-click adoption for another editing round. |
| **Group Bypass Presets** | Save combinations of enabled/bypassed groups and apply them with one click. |
| **Auto Exclusive Switch** | Route the only enabled branch to a shared output, without a manual switch widget. |
English and Simplified Chinese interfaces follow the ComfyUI language setting. The plugin uses the PyTorch, NumPy and Pillow already available in ComfyUI; no extra frontend build is required.
## Features
<details open>
<summary>Workbench screenshots</summary>
![Light workbench with an inline mask and workflow settings](preview/workbench-light.png)
![Dark workbench with the comparison divider](preview/workbench-compare-dark.png)
*Screenshots captured from the running plugin using a synthetic illustration. The comparison result is a CPU color inversion to demonstrate the divider, not an AI-generated inpaint.*
</details>
### Adachi Workbench · light and dark themes
Click **Open Adachi Workbench** on an Adachi Inpaint Workbench node. Workflows need the plugin’s Inline Mask, inpaint and Image Compare nodes; their IDs, titles, preset names and model filenames can be different. The workspace follows the selected node’s actual connections to its editor and result. If several candidates are ambiguous, choose them under **Workflow connections**. An enabled Group Bypass Presets node supplies its own preset buttons; missing or ambiguous controllers show a reminder.
Models, VAE, text encoders and prompts are discovered from the native execution graph, including Get/Set, Use Everywhere broadcasts and subgraph execution IDs. Intermediate model patches, ControlNet, multiple conditioning encoders, image processing and result postprocessing remain connected. Recognizable components get editing controls; other custom processing remains under the workflow’s control. No personal template IDs or model-family name matching are used.
New profiles inherit the workflow’s LoRA chains, including different positive/negative CLIP paths. Clear **Inherit workflow LoRAs** to use a custom list; an empty custom list disables recognized LoRAs. Custom LoRAs can also be applied when the editable graph has no LoRA loader. Refiner use is available in the model menu. Existing explicit workspace selections remain stored per preset.
Drag the divider between the image and settings to resize them. The width is remembered; double-click resets it, and arrow keys make small adjustments. Narrow screens keep the stacked layout. The theme icon remembers the light/dark preference. Hover controls for help.
The Generate button, width, height and denoise controls are near the top of the settings panel. Collapse the positive and negative prompt sections when you need more room.
Generation shows actual workflow stages and sampler steps above settings. You can keep painting, zooming and changing parameters; the queued task uses its submitted snapshot. Generated results open sliding comparison without replacing the source. Use the single before/after icon to toggle images, the pencil to edit, and **Adopt** to adopt. Crop adoption returns to editing with its region and mask intact. Source history can restore previous images, masks and crop state for the loaded browser session.
In the standalone workspace's sliding and before/after comparison views, scroll over the image to zoom, drag with the middle mouse button to pan, and use **Fit** to restore the fitted view. The workflow's Image Compare node continues to use the wheel for navigating the workflow canvas.
The comparison node and workspace recover lost execution notifications by queued task ID. Image loading reports actual received bytes, a percentage when size is known, then decoding; transient failures retry and can resume after reconnecting. Expired server-side previews require another run. Recovery does not create preview files. Updating backend code requires a full ComfyUI restart; frontend-only updates need a hard refresh.
The [official workflow](examples/Adachi-Workbench.json) is a starting point, not a required template. Validation also covers independently built multi-pipeline workflows, changed node IDs and renamed presets. Labels default to English; choose Simplified Chinese in ComfyUI settings for the Chinese interface. Other locales use the English fallback.
### Paint directly on the graph
Upload, drop or paste a source image and paint on **Inline Mask** itself. In Mask mode, uploading an image automatically fills the connected inpaint dimensions; a replacement of the same size keeps the mask, while a different-sized image starts with a fresh mask. Mask strokes stay in the browser and are saved with the workflow; painting does not require a disk save/load round-trip for every stroke.
Use the **✎ color paint** button at the lower left to paint directly on the image in either mode. Choose a color or **Alt + click** the visible image to sample one; **Ctrl + wheel** changes brush size. Color painting uses a separate layer with its own undo and redo, and the painted image becomes the source for subsequent inpainting. Click the button again to return to mask editing.
If browser draft storage is too small, the color layer may be saved as a PNG in ComfyUI's input directory instead of embedded in the workflow. Include that referenced file when moving the workflow to another machine.
While a source image uploads, a progress bar reports the bytes actually sent and then shows when the image is loading. On platforms requiring a special authentication fallback, the indicator stays indeterminate rather than showing an estimated percentage.
- Brush, soft edges, hardness, opacity, pen pressure, temporary erasing and shape tools.
- A compact **↑ upload / ⛶ fit / pin** toolbar, with a mode selector at the upper right. The pin (**Keep**) preserves the current view; use **Fit** first if desired. Plain wheel then navigates the workflow, while `Ctrl` + wheel still changes brush size and `Shift` + wheel changes hardness.
- Resize the editor horizontally or vertically. The pin state and node dimensions survive workflow reload.
- The connected **Adachi Inpaint Workbench** node hosts the painting controls and sampling parameters, leaving more space for the picture. Its painting tools, mask mode and fill content fold behind an arrow; **Undo / Redo / Invert / Fill / Clear / Import / Export** remain visible. Fold state is saved with the workflow.
- Bounded display buffers and cached image/mask compositing reduce redraw cost at large graph zoom; the saved mask retains its source resolution.
<details>
<summary>View the compact toolbar</summary>
<p align="center">
<img src="preview/inline-mask-toolbar.png" alt="Inline Mask with Upload, Fit and the active Keep pin" width="300">
</p>
*Toolbar reference supplied by the author; the source preview is unloaded in this screenshot.*
</details>
### Crop and blend in the same node
Choose **Mask** or **Crop & Blend** at the upper right of Inline Mask. Both modes receive images through **Adopt this result** in Image Compare; Inline Mask has no result input socket.
In Crop & Blend mode, choose one of six 64-aligned presets spanning 512–2048 pixels per side, including 1024 × 1536 (or custom dimensions in multiples of 64). The dashed rectangle follows the pointer by its upper-left corner; left-click locks it. The outside is darkened. A new region starts with an empty repaint mask. Paint inside the rectangle; brush and shape operations stay within its bounds. Use **◌ repaint mask** to return to repaint-mask editing. The position stays fixed for repeated passes. Size presets remain editable while locked; changing the preset, orientation or custom dimensions cancels the current selection and returns to pointer placement, merging any adopted layer first. The outline is a pure black dashed rectangle, and locking it automatically fills the connected Inpaint node's width and height from the crop, using the same rules as Use source size.
| Connection | Purpose |
| --- | --- |
| `Inline Mask.image` + `mask` → `Adachi Inpaint Workbench.image` + `mask` | Send the selected crop and its repaint mask. |
| `Inline Mask.image` → `Image Compare.original`; `Adachi Inpaint Workbench.image` → `Image Compare.result` | Compare the crop before adopting it. |
| `Inline Mask.full_image` → `Save Image.images` | Save the current full image with the **adopted** layer stitched in. |
Generate, inspect the result in **Image Compare**, then click **Adopt this result** to return it. There is no feedback wire and generation does not automatically replace the editor image. Use the inpaint node's **image** output for comparison; its `crop` output is the sampling canvas and can have a different size. In the [official workflow](examples/Adachi-Workbench.json), select **Inpaint**, then switch Inline Mask to **Crop & Blend**.
After adoption, the result appears in the selected area and the editor returns to the repaint mask, ready for another pass. Select **◐ blend layers** to paint the layer mask: brush keeps new pixels, **Alt** restores original pixels, and soft edges blend seams. The blend mask is independent of the repaint mask. While editing the blend mask, **hold the right mouse button** to preview the original and release it to return to the workspace. To select a new region, switch to **◌ repaint mask** and right-click (or press Escape); the visible result is merged before the selection is released. Left-click places the next region. The **↶ Undo** button undoes strokes in the active mask. Hover the bottom controls for instructions. For a generated batch, select the desired result in Image Compare before adopting it. A result tagged with an older crop is rejected after selecting another region.
Merging an adopted crop can take time on a slow connection: upload progress is shown while the selection is being released. If the upload fails, the selection remains in place and the operation can be retried.
### Inpaint the whole image or just the masked region
| Sampling area | Behaviour |
| --- | --- |
| **Whole image** · `whole_image` | Resize the full source to the sampling canvas, generate, then composite through the mask. |
| **Only masked** · `only_masked` | Crop the mask bounds with padding and aspect-ratio context, generate at the sampling size, then paste the region back into place. |
Both modes return a final `image` at the **source image's resolution**. Sampling `width` and `height` are independent controls; **Use source size** fills them from the uploaded image, rounded to the sampler's required multiple of 8.
The panel includes mask inside/outside, original/fill/noise/zero-latent content, denoise, sampler, scheduler, steps, CFG and seed. Advanced controls cover blur, growth, extra feathering, batch size and optional Refiner staging.
### Compare, adopt and refine
Move the pointer over **Image Compare**: **result on the left, original on the right**. Leave the picture to see the full result. The divider stays thin, the image stays fitted, and scrolling navigates the workflow background.
Loading errors and timeouts are retried automatically; **↻** reloads the comparison manually. If the temporary memory preview has expired after server restart or cache eviction, run the workflow again.
Results accumulate within an editing round, with the newest selected automatically. Each result retains its matching original; replayed preview notifications are not counted twice.
Use the **↓** button to download the latest generated result at its original resolution, even when an older result is selected in the list. For a batch, it downloads the last image in that batch.
Click **Adopt this result** to replace the inline source while preserving its mask and current-session undo/redo history. The comparison list resets to **1/1**; the next generation starts a new round. Failed adoption leaves the list intact. Adoption never queues a generation automatically.
### Switch entire workflow setups with presets
Save group states for your own setups: text-to-image, inpainting, model variants, ControlNet, upscaling, or any other grouped branches.
![Group Bypass Presets with a preset list on the left and per-group states on the right](preview/group-bypass-presets.png)
Choose a preset on the left, configure **Enabled / Bypassed / Keep current** on the right, or apply it from the node's right-click menu. Use the arrow to collapse or reopen the group details; this preference is saved in the workflow. Presets are stored in the workflow and need no wires or third-party group controller. Missing groups are reported; overlapping groups give the smaller group's setting priority.
### Automatically use the remaining branch
Connect two alternatives to **Auto Exclusive Switch** and bypass one. The remaining enabled branch flows to the shared output, with no `switch` toggle to maintain.
![Two Auto Exclusive Switch nodes routing positive and negative conditioning into Adachi Inpaint Workbench](preview/auto-exclusive-switch.png)
Use it for conditioning, strings, images, masks, models or latents; the two alternatives must use the same data type. Both enabled, or neither available, is an error rather than a silent preference for A.
KJNodes Get/Set routes and reroutes are supported. **Both Gets may stay enabled**: bypassing a branch's corresponding Set or source disables that branch. Workflow-aware server checks also handle saved subgraph output routes when the source node exists in the submitted execution graph.
## Installation
### Git clone
Run from your `ComfyUI/custom_nodes` directory:
```bash
git clone https://github.com/adachi0130/Adachi-Inpaint-Workbench.git
```
**Restart ComfyUI completely, then hard-refresh the browser with `Ctrl+F5`** (`Cmd+Shift+R` on macOS). Search for `Inline Mask`, `Adachi Inpaint Workbench` or `Adachi` in the node menu.
### Other installation options
- **ComfyUI Manager:** search for `Adachi-Inpaint-Workbench` in the Registry-backed Manager. Version availability depends on Registry validation; check the [Registry listing](https://registry.comfy.org/nodes/adachi-inpaint-workbench). You can also use the Manager's Git URL installation option with this repository's URL.
- **ZIP:** [download the repository](https://github.com/adachi0130/Adachi-Inpaint-Workbench/archive/refs/heads/main.zip), extract it, and place the plugin folder at `ComfyUI/custom_nodes/Adachi-Inpaint-Workbench/`. Keep `web/`, `backend.py` and `__init__.py` together.
- **Windows installer:** from the extracted plugin folder, run the following, pointing to the ComfyUI directory containing `main.py`:
```powershell
powershell -ExecutionPolicy Bypass -File .\install.ps1 -ComfyUIPath "D:\ComfyUI_windows_portable\ComfyUI"
```
脚本只复制本插件;更新时会把旧插件压缩备份到所选 ComfyUI 的 `user/adachi-workbench-backups/`,不会自动重启服务喵。
插件使用 ComfyUI 已有的 PyTorch、NumPy、Pillow,不需要额外 pip/npm 安装喵。
卸载时关闭 ComfyUI,再删除 `custom_nodes/ComfyUI-Adachi-Workbench` 即可喵。
The installer backs up an existing installation to `user/adachi-workbench-backups/`. It does not restart ComfyUI.
## 最快上手
For a Git installation, update with `git pull` inside the plugin folder, then restart ComfyUI and refresh the browser. Keep only one copy of this node pack in `custom_nodes`.
1. 将 `examples/01-inline-inpaint.json` 拖入 ComfyUI,选择自己的 checkpoint 喵。
2. 在 **内联蒙版 / Inline Mask** 节点载入、拖放或粘贴图片,直接在画布上涂抹需要重绘的部分喵。
3. 修改正负提示词,在 **WebUI 局部重绘 / WebUI Inpaint** 中选择 `area`、采样分辨率、采样器与重绘幅度,然后运行喵。
4. `image` 输出回贴后的完整原图,`crop` 输出实际采样画布,`mask` 输出该采样画布对应的蒙版喵。
## Quick start
**白色蒙版重绘,黑色保护;画布中橙色代表绘制区域** 喵。
当 `mask_mode=outside` 时会反相,重绘橙色之外的部分喵。
`mask_blur`、`mask_grow`、额外羽化会改变有效蒙版边界,边界内的混合像素也会变化;有效蒙版为零的像素逐值保留喵。
1. Drag [Adachi-Workbench.json](examples/Adachi-Workbench.json) into ComfyUI. Replace **YOUR_CHECKPOINT.safetensors** with your own compatible checkpoint; the template does not download models or require a LoRA.
2. Click **Text to image** in Group Bypass Presets, edit the neutral example prompts, and queue to create an image.
3. Click **Inpaint**. In **Inline Mask**, upload or drop a source image and paint the region to change. **White repaints; black preserves.** Orange is only the on-screen overlay.
4. Set the sampling area, dimensions and denoise in **Adachi Inpaint Workbench**. Queue on the graph, or click **Open Adachi Workbench** to edit in the separate workspace.
5. Compare the output. Adopt a result when you want it to become the next source, and continue editing with the mask intact.
## 两种重绘范围
| `area` | 对应操作 | 实际行为 |
|---|---|---|
| `whole_image` | 以原图范围重绘 | 将整张图缩放到 `width × height` 采样,再按照蒙版回贴原图 |
| `only_masked` | 以蒙版范围重绘 | 取蒙版包围框,加 `padding` 上下文,尽量扩展到目标比例,将局部放大到采样尺寸,生成后贴回原位置 |
`width/height` 指采样画布尺寸,须为 8 的倍数;两种模式的最终 `image` 都保持上传原图的分辨率喵。
这一点与 WebUI 整图模式常以目标宽高输出的行为有区别;需要目标尺寸的图时,可取 `crop`,或在完整输出后连接缩放节点喵。
`padding`、`mask_blur`、`mask_grow` 的单位都是原图像素喵。
蒙版贴近图像边缘时,局部模式用保比例填充保住整块蒙版,不会为匹配比例切掉蒙版喵。
`resize_mode` 的拉伸、居中裁剪、保比例填充作用于 `whole_image`;局部模式自动采用保留全部蒙版的变换喵。
## 画布操作
| 操作 | 使用方式 |
|---|---|
| 画笔 / 橡皮 | 工具栏切换,`B` / `E`;按住 `Alt` 或鼠标右键临时擦除,松开恢复原工具 |
| 矩形 / 自由套索 | `R` / `L`,拖动画出要重绘的区域 |
| 笔径 / 硬度 / 笔触 | 分别控制原图像素笔径、边缘硬度、绘制不透明度 |
| 橙色叠加 | 仅调节显示透明度,不改变真实蒙版 |
| 撤销 / 重做 | `Ctrl+Z` / `Ctrl+Shift+Z`,或 `Ctrl+Y` |
| 缩放 / 移动 | 滚轮缩放;空格拖动、鼠标中键或 `H` 移动;「适应」复位 |
| 笔径快捷键 | `[` / `]` |
| 快速调整笔径 | 鼠标放在画布上,按住 `Ctrl` 向上滚轮增大、向下滚轮减小;普通滚轮仍缩放图片 |
| 编辑器高度 | 拖动编辑器底部 `↕` 条上下拉伸;尺寸随工作流保存 |
| 反转 / 全选 / 清空 | 作用于当前绘制蒙版,可撤销 |
| 导入 / 导出蒙版 | 原始分辨率 PNG;黑白图按亮度导入,带透明度的图按 alpha 导入 |
| 原图 / 蒙版 / 叠加 | 切换预览,不改变输出 |
支持画笔压力和软笔刷;鼠标使用固定压力喵。
笔径、硬度、笔触和叠加四个滑条位于图片编辑区下方喵。
图片上传一次,之后每笔在浏览器内更新蒙版,并在工作流及运行参数中保存 PNG 数据,无需每画一笔都保存、重新加载磁盘文件喵。
超大图仍会消耗浏览器内存,PNG 编码耗时取决于分辨率和设备,不能保证所有图像都瞬间完成喵。
## 图像对比与连续重绘
添加 **图像对比 / Image Compare** 节点,将内联蒙版的 `image` 接到 `original`,最终完整重绘图 `image` 接到 `result`,运行后即可对比喵。
鼠标移动时,竖线左边显示原图、右边显示重绘结果,普通滚轮缩放图片;批量结果可在上方选择喵。
满意后点击 **采用此结果**,插件会把当前选中的结果上传为新的原图,回填到上游内联蒙版,并清空旧蒙版,随后可以继续涂抹下一处喵。
原图连接经过中间 IMAGE 节点时会向上寻找内联蒙版;若存在多个目标,可以在“回填到”中明确选择喵。
回填只在点击按钮时发生,不会自动开始下一轮生成;`image` 输出仍传递整个结果批次,可接保存图像喵。
对比预览保存在 ComfyUI 临时目录,清理或重启后可能需要重新运行;点击采用后的原图保存在 `input/adachi-inpaint/`,不依赖临时预览文件喵。
## 填充、批量与 Refiner
| `fill` | 行为 |
|---|---|
| `original` | 使用原图像素编码 latent |
| `fill` | 按蒙版周围颜色进行多尺度填充,再编码 latent |
| `latent_noise` | 在 masked latent 内填入与 seed 相关的噪声 |
| `latent_nothing` | 将 masked latent 置零 |
后两种 latent 填充由 **WebUI 局部重绘** 一体节点实现;独立 **重绘预处理** 只处理像素与蒙版,使用标准节点组合时请选择 `original` 或 `fill` 喵。
`denoise=0` 与空蒙版会直接返回原图,跳过 VAE 和采样;`batch_size` 为每张输入图的变体数,使用相同 seed 可重现同一批次喵。
多次批量运行可使用 ComfyUI 的队列数量以及 seed 的生成后控制;`batch_size` 控制单次运行数量喵。
普通 SD/SDXL checkpoint 保持 `inpaint_model=false`;专用 inpainting 模型才开启,届时调用 ComfyUI 原生 `InpaintModelConditioning` 喵。
可选接入兼容的 `refiner_model`,用 `refiner_switch` 指定 base 采样步骤占比;`0.8` 表示约前 80% 步骤由 base 执行喵。
建议用 Refiner 自身的 CLIP 编码并接入 `refiner_positive/refiner_negative`,未接入则沿用原条件,VAE 仍使用 base 侧的 VAE 喵。
`refiner_switch=1` 只用 base,`0` 只用 Refiner;已知 latent 格式不兼容时会报错喵。
## 使用标准采样工作流
`examples/02-modular-inpaint.json` 展示可自由更换采样器的连接方式喵。
```text
Inline Mask ── image/mask ──> Inpaint Prepare
pixels ──> VAE Encode ──> Set Latent Noise Mask ──> KSampler
mask ──────────────────────────┘ │
context ──────────────────────────────────────┐ │
│ ▼
Inpaint Composite <── VAE Decode
│
Save Image
```mermaid
flowchart LR
Source[Inline Mask] -->|image + mask| Inpaint[Adachi Inpaint Workbench]
Inputs[Model · VAE · Conditioning] --> Inpaint
Source -->|original| Compare[Image Compare]
Inpaint -->|result| Compare
Inpaint --> Save[Save Image]
Compare -. Adopt this result .-> Source
```
务必将 `Prepare.context` 接回 **原图回贴 / Inpaint Composite**,这样局部结果会回到原图坐标和尺寸喵。
标准示例针对普通 checkpoint;专用 inpainting checkpoint 应改用其需要的 conditioning 节点,或直接使用一体节点喵。
多个输入图各生成多个变体时,Composite 需要变体按原图分组,例如 `A,A,B,B`;一体节点已按此排列,外部重复 latent 节点如果输出 `A,B,A,B`,需先调整批次顺序喵。
### Essential connections
## 分享与兼容性
| From | To |
| --- | --- |
| `Inline Mask.image` / `mask` | `Adachi Inpaint Workbench.image` / `mask` |
| Your model / VAE / positive / negative conditioning | The matching inputs of `Adachi Inpaint Workbench` |
| `Inline Mask.image` | `Image Compare.original` |
| `Adachi Inpaint Workbench.image` | `Image Compare.result` and/or `Save Image.images` |
绘制蒙版自动写入工作流;默认原图只保存 ComfyUI `input/` 内的相对路径喵。
要让别人直接打开同一张图,勾选画布下方 **原图嵌入工作流** 后再保存工作流 JSON;对方安装本插件并选择自己的模型即可继续编辑喵。
不嵌入时,需要一并提供原图,并保持 `input/` 下的相对路径一致喵。
嵌入会增大工作流文件,原图和蒙版也会随工作流分享,因此分享前请确认图片内容喵。
The Refiner inputs are optional. Leave them disconnected unless you are using a compatible second-stage model.
这是原创实现的 WebUI 风格工作流,不承诺与 AUTOMATIC1111/Forge 的结果逐像素一致喵。
`soft_edge` 是额外高斯羽化,`soft_strength` 控制额外半径,**并未复刻 WebUI 的 Soft Inpainting 原算法及其完整参数** 喵。
采样器、调度器和 Refiner 使用当前 ComfyUI 的实现,填充算法也为本插件独立实现喵。
目前支持静态图像与 4 维 latent;不支持视频 VAE、WebUI 脚本/扩展和自动读取 WebUI 插件配置喵。
文件最大 64 MiB、图像最大 3200 万像素、单边最大 16384 像素;不附带模型或下载服务喵。
已在本机 ComfyUI 的隔离 CPU 服务验证节点加载、画布编辑、工作流/运行参数保存与恢复,并用 mock VAE/采样器验证后端;尚未用实际 checkpoint 验证生成质量和各模型家族/Refiner 的兼容性喵。
## Node reference
## English quick start
All nodes live under **Adachi-Inpaint-Workbench**. The group preset controller is frontend-only; the other six are user-facing backend nodes.
Copy the entire `ComfyUI-Adachi-Workbench` folder into `ComfyUI/custom_nodes`, restart ComfyUI, then hard-refresh your browser喵.
Search for `Inline Mask` or `WebUI Inpaint` under **安达工作台 / Adachi Workbench**喵.
No extra pip or npm packages are required beyond ComfyUI's PyTorch, NumPy and Pillow喵.
| Node | Purpose | Outputs |
| --- | --- | --- |
| **Inline Mask** | Mask painting or crop/inpaint/layer blending | `image`, `mask`, `full_image` |
| **Adachi Inpaint Workbench** | Prepare, sample and composite in one node | `image` = full composite; `crop` = sampling canvas; `mask` = sampling mask |
| **Image Compare** | Preview, compare and adopt results | `image` = unchanged result batch |
| **Group Bypass Presets** | Apply saved group states | No connections needed |
| **Auto Exclusive Switch** | Forward exactly one enabled alternative | `output` of the selected type |
| **Inpaint Prepare** | Crop/resize pixels and mask for a custom sampler workflow | `pixels`, `mask`, `context` |
| **Inpaint Composite** | Return a decoded local result to source coordinates | `image` |
Drag `examples/01-inline-inpaint.json` into ComfyUI, select a checkpoint, upload an image inside **Inline Mask**, paint, and run the workflow喵.
White repaints and black preserves; orange is the visual mask overlay喵.
Use `whole_image` to sample the full source extent, or `only_masked` to enlarge the padded mask bounding box for detailed local inpainting喵.
Sampling uses `width × height`; the final `image` output is composited at the original image resolution in both modes, while `crop` exposes the generated sampling canvas喵.
For modular sampling: `Prepare.pixels → VAE Encode → Set Latent Noise Mask → sampler → VAE Decode → Composite`. Connect `Prepare.mask` to the latent mask node and **always connect `Prepare.context` to `Composite.context`**.
The editor supports brush/eraser, rectangles, freehand lasso, undo/redo, invert, mask import/export, brush size/hardness/opacity, zoom, pan, drag-and-drop and clipboard images喵.
Paint with the left button; hold Alt or use the right button to erase temporarily, use Ctrl+wheel for brush size, zoom with the plain wheel, and pan while holding Space喵.
Enable **Embed source in workflow** before saving JSON to share the source image along with its mask喵.
## Canvas shortcuts
The **Image Compare** node takes `original` from Inline Mask and `result` from the final full-size generated image; hover to compare original on the left and result on the right喵.
Select a batch result if needed, then click **采用此结果 / Adopt this result** to upload it as the upstream editor's new source and clear the old mask for another pass喵.
| Action | Shortcut / control |
| --- | --- |
| Brush / eraser / rectangle / lasso | `B` / `E` / `R` / `L` |
| Temporary eraser | Hold `Alt`; release to return to the previous tool |
| Brush size | `Ctrl` + wheel, or `[` / `]` |
| Brush hardness | `Shift` + wheel |
| Preview original while blending | In **◐ blend layers**, hold right mouse button; release to restore the workspace |
| Release crop selection | In **◌ repaint mask**, right-click or press Escape; left-click to select a new region |
| Undo / redo | `Ctrl+Z` / `Ctrl+Shift+Z`, or `Ctrl+Y` |
| Image zoom / pan, when unpinned | Wheel / Space + drag, middle-button drag, or the pan tool (`H`) |
| Keep the current view | Fit first if desired, then use the pin; click again to unlock |
| Adjust editor height | Drag the bottom `↕` handle |
The all-in-one node adds original/color-fill/noise/zero-latent modes, native ComfyUI sampling, per-image batch variants, dedicated inpaint conditioning and an optional compatible Refiner stage喵.
`examples/02-modular-inpaint.json` demonstrates Prepare → standard VAE/KSampler nodes → Composite; use `original` or `fill` in that modular pipeline喵.
`soft_edge` adds Gaussian feathering; it does not implement AUTOMATIC1111 Soft Inpainting or its complete parameter set喵.
The plugin is an original MIT-licensed implementation and does not bundle WebUI/Forge source, models or extensions喵.
Real-checkpoint image quality and every model/Refiner family have not been tested; browser integration and CPU/mock-sampler regression tests have been run喵.
The painting panel also provides invert, fill, clear, PNG mask import/export, image/mask/overlay views and **Embed source in workflow**. Overlay opacity affects display only. Scrolling over buttons and parameter controls navigates the outer workflow; a gesture starting on the workflow keeps navigating it when the pointer crosses the picture.
Run the CPU tests from this plugin directory with your ComfyUI Python喵.
## Example workflows
```powershell
python tests/test_backend.py -v
Use the single [official template: Adachi-Workbench.json](examples/Adachi-Workbench.json). It includes text-to-image and inpainting branches, a shared checkpoint, Group Bypass Presets, automatic exclusive conditioning/image selection, comparison and saving. The preset controller stays outside the groups it controls. Select one preset at a time; enabling both branches intentionally fails exclusive selection.
All example labels are English. Prompts are newly written neutral examples; the checkpoint is a placeholder for your own model. Inpaint requires uploading a source first. Crop & Blend uses the same connected editor, so no second workflow is needed. For model families with separate model, text encoder and VAE loaders, replace the checkpoint block with compatible loaders and connect the matching outputs.
## Model and workflow compatibility
The inpaint node uses ComfyUI `MODEL`, `CONDITIONING`, `VAE`, image and mask inputs with `common_ksampler`. Matching interfaces alone do not establish compatibility with a model's required conditioning, sampler, latent format or reference-image pipeline. Use a matched model, encoder and VAE; the **inpaint_model** switch is for compatible dedicated inpainting architectures.
| Family / case | Evidence | What remains unverified |
| --- | --- | --- |
| WAI (SDXL-based) | End-to-end 512×512 generation without LoRA, including two- and six-step runs; template text-to-image and inpaint branches both ran. | This does not establish support for every SDXL checkpoint, LoRA or extension. |
| SD 1.5 | Workflow validation plus real checkpoint VAE encode/decode and masked compositing on CPU; `[1,4,8,8]` latent. | The sampler was mocked in the VAE test; a full diffusion generation has not been verified. |
| Anima / Wan-derived image VAE | Single-frame 5D latents and a real, randomly initialized Wan VAE were exercised. The user reported a remote workflow running after moving the CLIP encoder out of a subgraph. | No independently reproducible end-to-end run with real Anima weights was available in this audit. |
| FLUX, SD3, Qwen and other families | Not verified. | Their model-specific conditioning, guidance, sampling, reference-image and VAE requirements may need separate workflow nodes; compatibility cannot be assumed. |
Only still-image 4D latents and single-frame 5D `[B,C,1,H,W]` latents are supported; multi-frame video is rejected. To try a different model, first run its normal ComfyUI workflow, connect its matching model/conditioning/VAE to the inpaint node, start with **inpaint_model** off unless the architecture requires it, and check the protected pixels and output resolution. Start with a small image and few steps.
The standalone page requires this plugin's **Inline Mask**, **Adachi Inpaint Workbench** and **Image Compare** nodes. It follows supported connections, Get/Set, Use Everywhere and subgraph execution IDs, and preserves intermediate processing in the tested pipelines. Unknown custom loaders, LoRA classes, prompt fields or virtual routes may remain controlled by the workflow rather than being editable in the page; custom LoRA override cannot be guaranteed for unrecognized loaders. Select explicit nodes under **Workflow connections** when discovery is ambiguous and inspect the result for complex graphs. The page validates and runs the chosen comparison output and its dependencies: unrelated `Save Image` or other output branches do not automatically run there; queue the original workflow when those outputs are needed.
Group presets capture group-level enabled/bypassed states, not every node's individual mixed or muted state. Masks are embedded in saved workflows, while source images and unmerged crop layers normally reference files under `input/`; move those files too, or embed/merge before sharing. Source undo history is limited to the current browser session. Comparison previews are process-local and may expire on eviction or server restart; deployments with multiple backend instances need requests to reach the same instance. Local network-failure simulations passed, but a long-running real cloud soak test has not been performed. For crop/blend and source-history operations, use HTTPS or localhost: the browser's `crypto.randomUUID()` is unavailable in insecure contexts such as many plain-HTTP LAN addresses.
## Compatibility and important behaviour
- **Static images:** ordinary 4D `[B,C,H,W]` latents and single-frame 5D `[B,C,1,H,W]` image latents are supported, including the Wan-derived VAE path used by Anima. Supply the model family's matching VAE and conditioning. This is not multi-frame video inpainting.
- **Anima:** for ordinary masked img2img, leave **inpaint_model** disabled. That option is for dedicated inpainting architectures, not a requirement for painting a mask.
- **Source size:** the final composite keeps the source resolution in both sampling areas. Use `crop` or a separate resize node for a target-size output.
- **Crop layers:** the fusion mask and crop coordinates are saved in the workflow; the latest layer is a relative image reference under `input/adachi-inpaint-layers/`. Include that file when moving an unmerged edit between machines, or merge layers and embed the source first.
- **Sharing:** masks are embedded in saved workflows. Source images use a relative `input/` path by default; enable **Embed source in workflow** to include the picture too. Embedded images increase JSON size. If an embedded source would exceed browser draft storage when loading a workflow, uploading an image, changing modes or enabling embedding, the plugin first uploads an exact source copy and switches to an input-file reference, preserving masks and layers. To intentionally keep a very large source embedded, disable ComfyUI browser workflow persistence before embedding; exported files are not subject to the browser draft quota. If the page already reports draft failures on every edit, save with **Ctrl+S**, then reload with **Ctrl+F5** to reset ComfyUI's disabled draft-storage state.
- **Adoption:** source and result dimensions must match to preserve mask coordinates. Undo/redo commands survive adoption in the current session; saving/reloading retains the mask, not the command history.
- **Comparison previews:** new originals and results are held in server memory instead of temporary image files. The default cache is 512 MiB and at most 512 comparison batches; set `ADACHI_COMPARE_MEMORY_MIB` to 64–4096 before starting ComfyUI to change it. Cache eviction or server restart expires old comparisons and their download references; rerun the workflow to refresh them. A single batch larger than the limit reports an error. Previous disk-based preview references remain supported while available, and expired old plugin preview files are still cleaned up. Images adopted into the editor are saved separately under `input/adachi-inpaint/` or `input/adachi-inpaint-layers/`.
- **Scope:** this is an original WebUI-style implementation. It does not promise identical WebUI pixels, scripts or extension behaviour. Extra soft-edge feathering is not WebUI's full Soft Inpainting algorithm.
<details>
<summary>Sampling, fill and batch details</summary>
- Sampling dimensions use multiples of 8. Padding, blur and mask growth are measured in source pixels; effective mask edges can change after blur/growth, while pixels with zero effective mask remain unchanged.
- `mask_mode=outside` inverts the painted region before mask processing.
- `resize_mode` controls whole-image resizing. Masked-area mode uses an aspect-preserving transform that retains the full mask, including at source edges.
- Fill modes: **original**, surrounding-colour **fill**, **latent noise**, and **zero latent**. The latent modes belong to Adachi Inpaint Workbench; the modular Prepare node handles pixels and masks.
- An empty mask or `denoise=0` skips VAE/sampling. `batch_size` is variants per input image, not ComfyUI queue count.
- For modular multi-source batches, group variants per source (`A,A,B,B`), rather than interleaving them (`A,B,A,B`).
- Optional Refiner uses `refiner_model` and `refiner_switch`; `0.8` runs the last 20% with the Refiner. Compatible Refiner conditioning is recommended. A switch of `1` uses only the base model; `0` uses only the Refiner.
- Input limits: 64 MiB per file, 32 megapixels and 16384 pixels per image edge. Large-image painting and PNG encoding still depend on browser memory and hardware.
</details>
## Troubleshooting and development
**Old UI after updating?** Fully restart ComfyUI and hard-refresh the browser. Backend Python changes need a server restart; refreshing the page alone is insufficient.
**Exclusive switch reports no enabled input?** Check the real source and corresponding Set, not only the visible Get nodes. Keep the selector outside the groups it controls. If an integration still drops a subgraph output, placing the CLIP encoder outside that subgraph is a practical workaround. For a report, include the workflow, plugin/frontend versions and full error text; current errors show frontend, workflow and submitted-input flags.
**An image is missing after opening someone else's workflow?** Supply the source at its relative input path, or have the sender save with source embedding enabled. Expired comparison previews require regeneration.
CPU regression tests:
```bash
python -m unittest discover -s tests -v
node --experimental-default-type=module tests/test_exclusive_routes.mjs
node --experimental-default-type=module tests/test_compare_history.mjs
```
See [verification notes](VERIFICATION.md) for tested configurations and limits, [changelog](CHANGELOG.md) for updates, and [rollback instructions](ROLLBACK.md) for returning to an earlier version. Model-family and image-quality coverage is not exhaustive.
## Release history
| Versions | Highlights and fixes |
| --- | --- |
| 2.1.1 | Streamlined comparison Fit controls, moved repaint-area choices below Generate, and combined color-paint controls with the lower toolbar. |
| 2.1.0 | Color-paint layer, crop merge progress, workspace comparison zoom, reordered controls, collapsible prompts and GPL-3.0-only licensing. |
| 2.0.1 | Weak-network retry and cancellation, preview resource cleanup, rollback races, dense-mask memory reduction and safer malformed-workflow handling. |
| 2.0.0 | Standalone light/dark workbench, workflow discovery, editable generation, task progress, result recovery and unified template. |
| 1.0.1 / 1.0.0 / 0.9 | Stable release; preview reload/retry, in-memory comparisons and real upload progress. |
| 0.8 | Crop adoption and dimension sync, latest-result download, different-sized upload refresh. |
| 0.7 | Preserve masks on replacement, source download and pin-current-view behavior. |
| 0.6 | Inline crop/blend, locked selection and dimension sync, right-click preview/reselection and draft recovery; magic wand and quick selection were later removed. |
| 0.5 | Group presets and automatic exclusive switch, mask retention, 5D image VAE support and canvas performance fixes. |
| 0.4 / 0.3 / 0.2 | Inline controls and vertical resizing, comparison/adoption, temporary eraser and bilingual node UI. |
The [complete changelog](CHANGELOG.md) lists individual releases and fixes.
## Credits and license
Created by **Adachi / 安达**. The current project is licensed under [GNU GPL-3.0-only](LICENSE). You may use it commercially; when distributing the covered program or modifications, follow GPL-3.0 source and license requirements. Releases through v2.0.1 were distributed under MIT; recipients retain those earlier permissions. The original copyright and license notice is preserved in [LICENSES](LICENSES/MIT-previous-releases.txt). The current branch's GPL terms do not retroactively revoke the earlier MIT grant.
Built for [ComfyUI](https://github.com/Comfy-Org/ComfyUI), with familiar inpainting controls inspired by WebUI and hover-comparison interaction inspired by [rgthree-comfy](https://github.com/rgthree/rgthree-comfy). This repository contains its own implementation; those projects are not bundled dependencies.
Bug reports and workflow examples are welcome in [Issues](https://github.com/adachi0130/Adachi-Inpaint-Workbench/issues).
+334
View File
@@ -0,0 +1,334 @@
<div align="center">
# Adachi-Inpaint-Workbench
**绘制蒙版,局部重绘,对比结果,继续精修。**
直接在 ComfyUI 工作流内操作的重绘工作台,提供 WebUI 风格参数,以及分组预设、自动分支选择工具。
[English](README.md) · **简体中文**
[![GPL-3.0 许可证](https://img.shields.io/badge/License-GPL--3.0--only-38966b)](LICENSE)
[![ComfyUI 自定义节点](https://img.shields.io/badge/ComfyUI-Custom_Nodes-4776b4)](https://github.com/Comfy-Org/ComfyUI)
[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-4776b4)](pyproject.toml)
[![插件检查](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml/badge.svg)](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml)
[功能介绍](#功能介绍) · [安装](#安装) · [快速开始](#快速开始) · [节点一览](#节点一览) · [示例工作流](#示例工作流)
![在同一个 ComfyUI 工作流中绘制蒙版、设置重绘参数并对比结果](preview/nodes2-inline.png)
*保留工作台早期预览图;下方保留旧工具栏截图;v0.6 使用上传箭头、适应、图钉。*
</div>
## 为什么做这个工作台?
局部重绘时,常常需要在工作流、蒙版编辑器和生成结果之间来回切换。本插件把这段操作放回画布:直接在节点内涂蒙版,生成并对比结果,再把满意的图片作为下一轮原图,**保留已经画好的蒙版**继续精修。
| 工具 | 用途 |
| --- | --- |
| **内联蒙版 · Inline Mask** | 同一节点内涂蒙版,或裁切、重绘、双图层融合。 |
| **安达重绘工作台 · Adachi Inpaint Workbench** | 整图或蒙版区域采样,使用熟悉的重绘参数,按原图尺寸回贴结果。 |
| **图像对比 · Image Compare** | 鼠标划线对比、查看结果历史,一键采用后继续重绘。 |
| **组忽略预设 · Group Bypass Presets** | 保存各组的打开/忽略组合,一键切换工作流配置。 |
| **自动二选一 · Auto Exclusive Switch** | 自动输出唯一启用的分支,不用手动设置 switch。 |
界面支持英文和简体中文,跟随 ComfyUI 的语言设置。使用 ComfyUI 已有的 PyTorch、NumPy 和 Pillow,无需额外构建前端。
## 功能介绍
<details open>
<summary>工作台实机截图</summary>
![浅色工作台中的内联蒙版和工作流设置](preview/workbench-light.png)
![深色工作台中的图片对比分割线](preview/workbench-compare-dark.png)
*截图来自实际运行的插件,图片是自制示例;对比结果使用 CPU 反相演示分割线,并非 AI 重绘结果。*
</details>
### Adachi Workbench · 黑白主题
在任意安达重绘工作台节点中点击「打开安达工作台」。工作流需包含本插件的内联蒙版、重绘和图像对比节点,但节点编号、标题、预设名称及模型文件名均不需与示例一致。页面沿所选重绘节点的实际连线寻找图像和结果;多个候选无法唯一判断时,在「工作流连接」中选择。已启用的组忽略预设节点提供预设按钮,缺失或存在多个控制器时会提醒。
模型、VAE、文本编码器和提示词从原生执行图读取,支持 Get/Set、Use Everywhere 广播及子图执行编号。模型采样设置、ControlNet、多编码器条件合并、图像处理和结果后处理会保留;可识别的组件显示编辑控件,其余自定义处理按原工作流执行,不按私人节点编号或模型家族名称重新接线。
新方案默认「沿用工作流 LoRA」,保留原有 LoRA 和正负 CLIP 分叉。取消勾选后使用自定义列表,空列表会禁用已识别的 LoRA;原工作流没有 LoRA 节点也可添加自定义 LoRA。Refiner 可在模型菜单选择,旧版已明确设置的方案仍会保留。
画布与配置之间的分隔条可以左右拖动,宽度会记住;双击恢复默认,方向键可微调。窄屏保持上下排列,右上角可切换并记住黑白主题。操作说明放在悬停提示中。
生成按钮、宽度、高度和重绘幅度位于设置面板靠上位置。正面和负面提示词可以折叠,腾出更多操作空间。
生成时可以继续涂蒙版、缩放、移动和调整参数,已提交任务使用当时的快照。工作流设置上方显示真实阶段和采样步数;结果默认滑动对比,前后图共用一个切换图标,点击「采用」才替换原图。裁切融合采用后返回编辑,保留选区与蒙版。下方可回退或选择之前的原图,历史限当前已加载的浏览器会话。
独立工作台的滑动对比和前后图视图支持在图片上滚动鼠标滚轮缩放、按住鼠标中键拖动画面,点击「适应」恢复适合画布的视图。工作流内的图像对比节点仍使用滚轮操作工作流画布。
图像对比节点和网页按本次任务编号找回丢失的结果通知。图片加载显示真实接收字节、已知总大小时的百分比及解码状态;弱网失败自动重试,重连后可恢复,不产生预览文件。服务器重启或缓存淘汰造成的预览过期仍需重新运行。后端升级需重启 ComfyUI,仅前端改动强刷网页即可。
[官方模板](examples/Adachi-Workbench.json) 仅作为起点,不是必需的固定工作流。已验证不同编号和预设名的副本,以及完全新建的多链路工作流。界面默认英文,在 ComfyUI 设置中选择简体中文后显示中文,其他语言暂时回退英文。模型权重须与原工作流匹配;缺失文件会列出文件名和目标目录。
### 直接在工作流里画蒙版
在**内联蒙版**上传、拖入或粘贴原图,然后直接在节点画布上涂抹。普通蒙版模式上传图片时会自动填写已连接的局部重绘宽高;替换同尺寸图片时保留蒙版,尺寸不同则载入新图并重置蒙版。蒙版在浏览器内更新,随工作流保存,不需要每画一笔都保存、加载磁盘文件。
两种模式都可以点击左下角的 **✎ 彩色画笔**直接在图像上绘制。可手动选色,或按住 **Alt 并点击图像**吸取当前可见颜色;使用 **Ctrl + 鼠标滚轮**调整笔径。彩色画笔使用独立图层,提供自己的撤销和重做,画完的图像会作为后续重绘的原图。再次点击画笔按钮可回到蒙版编辑。
如果浏览器草稿存储空间不足,画笔图层可能会保存为 ComfyUI 输入目录中的 PNG,而不是嵌入工作流;将工作流转移到其他机器时,也需要带上引用的文件。
上传原图时,进度条显示浏览器实际发送的数据比例,上传完成后提示正在加载图片。需要特殊认证回退的平台会显示不定进度,不会虚构百分比。
- 支持画笔软边、硬度、不透明度、数位笔压感、临时擦除和形状工具。
- 顶部使用**↑ 上传、⛶ 适应、图钉**,右上角切换模式。图钉的悬停提示为「保持」:开启后保持当前视图;需要适应画面时请先点击「适应」再钉住,普通滚轮控制工作流背景,`Ctrl` + 滚轮调整笔径,`Shift` + 滚轮调整硬度。
- 支持横向、纵向调整编辑区大小;图钉状态和节点尺寸都能随工作流保存。
- 连到 **安达重绘工作台**后,画笔工具和采样参数显示在重绘节点内,让图片编辑区保持简洁。画笔工具、蒙版内外和填充内容收进箭头折叠区,**撤销、重做、反转、全选、清空、导入蒙版、导出**整行始终显示;折叠状态随工作流保存。
- 放大工作流时限制显示画布的分配尺寸,并缓存原图/蒙版合成,减少反复重绘;实际保存的蒙版仍保持原图分辨率。
<details>
<summary>查看简洁工具栏</summary>
<p align="center">
<img src="preview/inline-mask-toolbar.png" alt="内联蒙版的上传、适应和已开启的保持图钉" width="300">
</p>
*作者提供的工具栏示意图;截图中的原图预览尚未载入。*
</details>
### 一个节点内裁切、重绘和融合
在内联蒙版右上角选择**蒙版**或**裁切融合**。两种模式都通过图像对比节点的**采用此结果**回传图片;内联蒙版不再提供结果输入口。
裁切融合模式保留六组常用预设,宽高均为 64 的倍数,范围 512~2048(含两端);可用交换按钮切换横竖方向,自定义尺寸按 64 像素递增。虚线框的左上角跟随鼠标,左键点击锁定,框外压暗。新选区的蒙版为空,需要自己在框内涂抹;画笔和形状操作限制在选区内,点击 **◌ 重绘蒙版**可返回编辑重绘范围。选区框为纯黑虚线;点击锁定时,相连 Inpaint 节点会自动填写裁片宽高,等同于点一次「使用原图宽高」。选区位置固定,支持反复重绘同一处。选定后仍可切换预设、交换横竖或修改自定义宽高;修改会自动取消当前选区,重新跟随鼠标定位,已有采用图层会先合并保留。
| 连线 | 用途 |
| --- | --- |
| `内联蒙版.image`、`mask` → `安达重绘工作台.image`、`mask` | 把裁片和重绘蒙版送去生成。 |
| `内联蒙版.image` → `图像对比.original`;`安达重绘工作台.image` → `图像对比.result` | 先对比裁片,再选择采用。 |
| `内联蒙版.full_image` → `保存图像.images` | 保存包含**已采用图层**的完整图像。 |
先生成,在**图像对比**中查看,满意后点击**采用此结果**,才会嵌回当前裁切位置;没有回接输入线,也不会生成后自动替换。比较结果使用重绘节点的 **image** 输出;`crop` 是采样画布,尺寸可能不同。在[官方模板](examples/Adachi-Workbench.json)中选择 **Inpaint**,再将内联蒙版切换到**裁切融合**即可。
采用后结果显示在选区中,编辑器回到重绘蒙版,可直接继续生成。点击 **◐ 图层融合**可编辑融合蒙版:画笔保留新图,按住 **Alt** 擦回原图,软笔刷可融合接缝;融合蒙版与重绘蒙版相互独立。编辑融合蒙版时,**按住右键看原图,松开恢复工作区**。要选择新区域,请切回 **◌ 重绘蒙版**后右键,或按 Escape;当前可见结果会先合并,再取消选定,接着左键选择新区域。**↶ 撤销**可撤销当前蒙版的涂抹,鼠标悬停底部控件可查看说明。批量生成时,在图像对比中选中想要的结果再采用;换到其他选区后,带有旧选区标记的结果会拒绝回贴。
网络较慢时,取消选区前合并已采用的裁片可能需要一些时间,界面会显示上传进度;上传失败会保留选区,可再次尝试。
### 整图重绘与蒙版区域重绘
| 采样区域 | 工作方式 |
| --- | --- |
| **整图范围** · `whole_image` | 将整张原图调整到采样尺寸,生成后通过蒙版合成回原图。 |
| **仅蒙版区域** · `only_masked` | 按蒙版边界截取区域,加入 padding 和宽高比所需的上下文,采样后回贴到原位置。 |
两种模式最终输出的 `image` 都保持**原图尺寸**。采样宽、高独立设置;**使用原图宽高**按钮可一键填写,并按采样器要求对齐到 8 的倍数。
面板提供蒙版内/外、原图像素/填充/潜空间噪声/空白潜空间、重绘幅度、采样器、调度器、步数、CFG 和种子。高级设置包括蒙版模糊、扩张、额外羽化、批次数量和可选 Refiner。
### 对比结果,采用后继续精修
鼠标移入**图像对比**:**左边是重绘结果,右边是原图**;移开后显示完整结果。分隔线保持细线,图片持续适应编辑区,滚轮作用于工作流背景。
网络错误或加载超时时会自动重试,也可点击 **↻** 手动重新加载;如果临时预览已因服务器重启或缓存淘汰而失效,请重新运行工作流。
同一轮编辑中的结果会累计保存并默认选中最新一张,每张结果对应当时的原图;重复的预览通知不会重复计数。
点击 **↓** 可以按原始分辨率下载最新生成的结果,即使列表中正在查看旧图也会下载最新一张;批量生成时下载该批最后一张。
点击**采用此结果**,把选中的图片回填为内联蒙版的新原图,同时保留蒙版和当前会话中的撤销/重做记录。结果列表立即回到 **1/1**,下一次生成开启新一轮。采用失败时保留原列表;采用操作不会自动开始生成。
### 一键切换整套分组配置
为自己的工作流保存多套组合,例如文生图、修图、不同模型、ControlNet、放大等分支的打开/忽略状态。
![组忽略预设:左侧选择预设,右侧设置每个组的状态](preview/group-bypass-presets.png)
左侧点选预设,右侧配置各组**打开 / 忽略 / 保持当前**,也能通过节点右键菜单应用。点击箭头可折叠或展开右侧分组详情,状态随工作流保存。预设随工作流保存,不需要连线,也不依赖第三方组控制节点。已删除的组会显示提示;分组重叠时,较小组的设置优先。
### 自动使用剩下的一路输入
将两路备选输入连接到**自动二选一**,忽略其中一路后,另一条有效分支自动流向共同输出,无需手动切换 `switch`。
![两个自动二选一节点分别把正面、负面条件连接到 安达重绘工作台](preview/auto-exclusive-switch.png)
可用于提示词条件、字符串、图像、蒙版、模型、潜空间等数据;两路输入应为同一类型。两路都启用或都不可用时会报错,不会擅自优先选择 A。
支持 KJNodes 的 Get/Set 和重路由节点。**两个 Get 可以一直开启**:忽略其中一路对应的 Set 或上游源节点,就会停用那条分支。请求带有工作流信息时,后端也会核对已保存的子图输出;执行请求中仍存在相应源节点时,可恢复丢失的有效连接。
## 安装
### Git 安装
在 `ComfyUI/custom_nodes` 目录运行:
```bash
git clone https://github.com/adachi0130/Adachi-Inpaint-Workbench.git
```
**完整重启 ComfyUI,再按 `Ctrl+F5` 强制刷新网页**,macOS 可用 `Cmd+Shift+R`。在节点菜单搜索 `Inline Mask`、`Adachi Inpaint Workbench` 或 `Adachi`。
### 其他安装方式
- **ComfyUI Manager:**可搜索 `Adachi-Inpaint-Workbench`;各版本的可用性取决于注册表验证结果,参见[注册表页面](https://registry.comfy.org/nodes/adachi-inpaint-workbench)。也可以使用 Manager 的 Git URL 安装方式填写本仓库地址。
- **ZIP:**[下载仓库压缩包](https://github.com/adachi0130/Adachi-Inpaint-Workbench/archive/refs/heads/main.zip),解压后放到 `ComfyUI/custom_nodes/Adachi-Inpaint-Workbench/`,保留完整的 `web/`、`backend.py` 和 `__init__.py`。
- **Windows 安装脚本:**在解压后的插件目录运行以下命令,路径指向包含 `main.py` 的 ComfyUI 目录:
```powershell
powershell -ExecutionPolicy Bypass -File .\install.ps1 -ComfyUIPath "D:\ComfyUI_windows_portable\ComfyUI"
```
脚本会将已有安装备份到 `user/adachi-workbench-backups/`,不会自动重启 ComfyUI。
Git 安装用户可在插件目录运行 `git pull` 更新,然后重启 ComfyUI 并刷新网页。`custom_nodes` 中只保留一份本插件,避免重复加载。
## 快速开始
1. 将 [Adachi-Workbench.json](examples/Adachi-Workbench.json) 拖入 ComfyUI,把 **YOUR_CHECKPOINT.safetensors** 换成自己兼容的 checkpoint;模板不下载模型,也不要求 LoRA。
2. 在组忽略预设中点击 **Text to image**,修改中性的示例提示词,运行生图。
3. 点击 **Inpaint**,在**内联蒙版**上传或拖入原图,涂抹需要修改的区域。**白色重绘,黑色保留**,橙色仅为显示叠加。
4. 在**安达重绘工作台**设置重绘范围、宽高和重绘幅度,直接运行,或点击**打开安达工作台**进入独立界面编辑。
5. 对比结果,满意后采用为新原图,保留蒙版继续修改。
```mermaid
flowchart LR
Source[内联蒙版] -->|image + mask| Inpaint[安达重绘工作台]
Inputs[模型 · VAE · 文本条件] --> Inpaint
Source -->|original| Compare[图像对比]
Inpaint -->|result| Compare
Inpaint --> Save[保存图像]
Compare -. 采用此结果 .-> Source
```
### 必要连线
| 从哪里 | 连到哪里 |
| --- | --- |
| `内联蒙版.image` / `mask` | `安达重绘工作台.image` / `mask` |
| 模型 / VAE / 正面条件 / 负面条件 | `安达重绘工作台`对应输入 |
| `内联蒙版.image` | `图像对比.original` |
| `安达重绘工作台.image` | `图像对比.result`,以及需要时的 `保存图像.images` |
Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接,否则留空即可。
## 节点一览
分类为 **Adachi-Inpaint-Workbench**。组忽略预设是前端控制节点,其余六个为用户使用的后端节点;
| 节点 | 用途 | 输出 |
| --- | --- | --- |
| **内联蒙版 · Inline Mask** | 普通蒙版或裁切重绘、双图层融合 | `image`、`mask`、`full_image` |
| **安达重绘工作台 · Adachi Inpaint Workbench** | 一体化准备、采样、合成 | `image`:完整合成图;`crop`:采样画布;`mask`:采样蒙版 |
| **图像对比 · Image Compare** | 预览、对比、采用结果 | `image`:原样传递结果批次 |
| **组忽略预设 · Group Bypass Presets** | 应用已保存的组状态 | 无需连线 |
| **自动二选一 · Auto Exclusive Switch** | 传递唯一启用的输入 | `output`:被选中分支的数据类型 |
| **重绘准备 · Inpaint Prepare** | 为自定义采样流程裁剪、调整图像和蒙版 | `pixels`、`mask`、`context` |
| **重绘合成 · Inpaint Composite** | 将局部解码结果回贴到原图 | `image` |
模块化流程可使用 `Prepare.pixels → VAE 编码 → 设置潜空间噪声蒙版 → 采样器 → VAE 解码 → Composite`。`Prepare.mask` 连接噪声蒙版节点,**务必把 `Prepare.context` 连到 `Composite.context`**。
## 画布快捷操作
| 操作 | 快捷键或控件 |
| --- | --- |
| 画笔 / 橡皮擦 / 矩形 / 套索 | `B` / `E` / `R` / `L` |
| 临时橡皮擦 | 按住 `Alt`,松开后恢复之前的工具 |
| 调整笔径 | `Ctrl` + 滚轮,或 `[` / `]` |
| 调整硬度 | `Shift` + 滚轮 |
| 融合时临时看原图 | 在 **◐ 图层融合**中按住右键,松开恢复工作区 |
| 取消裁切选定 | 在 **◌ 重绘蒙版**中右键或按 Escape,再左键选择新区域 |
| 撤销 / 重做 | `Ctrl+Z` / `Ctrl+Shift+Z`,或 `Ctrl+Y` |
| 未固定时的图片缩放 / 移动 | 滚轮 / 空格拖动、中键拖动,或移动工具 `H` |
| 保持当前视图 | 需要时先点击「适应」,再点击图钉;再点一次解除 |
| 调整编辑区高度 | 拖动底部 `↕` 手柄 |
画笔面板还提供反转、全选、清空、PNG 蒙版导入/导出、原图/蒙版/叠加视图和**原图嵌入工作流**。叠加不透明度只影响显示。鼠标在按钮、参数区滚动时控制外部工作流;从工作流背景开始的连续滚轮手势,移入图片后仍控制工作流。
## 示例工作流
只提供一个[官方模板:Adachi-Workbench.json](examples/Adachi-Workbench.json),包含生图与修图两条分支、共用模型、组忽略预设、条件/图像自动二选一、图像对比和保存。预设控制器位于受控组外;每次选择一个预设,同时打开两路会触发二选一的检查。
模板标题使用英文,提示词为重新编写的中性示例,模型为占位名称,需自行选择。修图前请先上传原图;裁切融合直接切换同一个内联蒙版即可。使用分离模型、文本编码器和 VAE 的模型家族时,请用匹配的加载节点替换 checkpoint 区块并连接对应输出。
## 模型和工作流兼容性
重绘节点使用 ComfyUI 的 `MODEL`、`CONDITIONING`、`VAE`、图像和蒙版输入,并调用 `common_ksampler`。接口能接上,并不代表模型所需的条件、采样器、潜空间格式或参考图流程一定兼容;请使用相互匹配的模型、文本编码器和 VAE。**inpaint_model** 只用于兼容的专用修复架构。
| 模型/情况 | 已有证据 | 尚未验证 |
| --- | --- | --- |
| WAI(基于 SDXL) | 无 LoRA 的 512×512 完整生成已通过,包括 2 步、6 步测试;官方模板的生图和修图分支都已运行。 | 不代表所有 SDXL 模型、LoRA 或扩展都已验证。 |
| SD 1.5 | 工作流校验通过;本次用真实 checkpoint 的 VAE 在 CPU 上完成编码、解码和蒙版回贴,潜空间为 `[1,4,8,8]`。 | VAE 测试中的采样器使用模拟实现,尚未完成完整扩散生成验证。 |
| Anima/Wan 系图像 VAE | 已测试单帧 5D 潜空间和真实但随机初始化的 Wan VAE;主人反馈曾将 CLIP 编码器移出子图后跑通远程工作流。 | 本次没有可独立复核的真实 Anima 权重端到端生成记录。 |
| FLUX、SD3、Qwen 等其他模型 | 尚未验证。 | 专用条件、引导、采样、参考图及 VAE 流程可能需要额外节点,不能直接保证兼容。 |
目前只支持静态图 4D 潜空间或单帧 5D `[B,C,1,H,W]` 潜空间,真正的多帧视频会被拒绝。尝试新模型时,先确保它自己的 ComfyUI 流程能运行,再把匹配的模型、条件和 VAE 接到重绘节点;除非架构明确需要,否则先关闭 **inpaint_model**,用小图和少量步数检查尺寸与蒙版外像素。
独立页面需要本插件的**内联蒙版、安达重绘工作台和图像对比**节点。已测试的流程可跟随连线、Get/Set、Use Everywhere 和子图执行编号,并保留中间处理链;未知自定义加载器、LoRA、提示词字段或虚拟路由可能仍由原工作流控制,不能保证在页面里识别和编辑,也不能保证自定义 LoRA 覆盖适用于未知加载器。候选不唯一时请在**工作流连接**中选择,复杂流程应检查实际输出。页面只校验并运行所选图像对比及其依赖,不会自动运行无依赖关系的「保存图像」等其他输出;需要它们时请运行原工作流。
组预设保存的是组级打开/忽略状态,不会完整记录组内逐节点的混合或静音状态。蒙版随工作流保存,原图和未合并的裁切图层默认引用 `input/` 文件;换机时请带上文件,或先嵌入原图/合并图层。原图回退历史仅限当前浏览器会话。对比预览保存在单个服务进程内,重启或被淘汰后可能失效;多实例部署需保证请求落到同一个后端。本地已模拟断网重连,但未做仙宫云长期实机压测。裁切融合和历史记录使用浏览器的 `crypto.randomUUID()`:请优先使用 HTTPS 或 localhost,部分普通 HTTP 局域网地址下可能不可用。
## 兼容性与重要行为
- **静态图片:**支持常规四维 `[B,C,H,W]` 和单帧五维 `[B,C,1,H,W]` 图像潜空间,包括 Anima 使用的 Wan 衍生 VAE 路径;需提供该模型配套的 VAE 和文本条件,不是多帧视频重绘。
- **Anima:**普通带蒙版图生图请关闭 **inpaint_model / 专用重绘模型**。该选项用于专用 inpaint 架构,并非画了蒙版就需要开启。
- **原图尺寸:**两种采样区域最终都按原图尺寸合成;如果要目标采样尺寸的图片,使用 `crop` 或另接调整尺寸节点。
- **裁切图层:**选区和融合蒙版会保存到工作流,最新重绘图层引用 `input/adachi-inpaint-layers/` 中的图片。跨机器移动尚未合并的编辑时需带上这张图,或先合并图层再嵌入原图。
- **分享工作流:**蒙版会写入 JSON。原图默认只存 `input/` 下的相对路径,开启**原图嵌入工作流**才会把图片也写进去,文件会变大。加载工作流、上传图片、切换模式或勾选嵌入时,如果浏览器草稿空间不足,插件先保存一份完全相同的原图到 input 图片目录,再改用文件引用,保留蒙版、图层和笔画记录。若确实要把大图嵌入 JSON,可先关闭 ComfyUI 的浏览器工作流持久化,再勾选嵌入;导出文件不受浏览器草稿限额影响。若当前页面已经每次操作都提示草稿失败,请先按 **Ctrl+S** 保存,再按 **Ctrl+F5** 刷新一次,重置 ComfyUI 本页已停用的草稿状态。
- **采用结果:**结果尺寸必须与原图一致,才能保留蒙版坐标。撤销/重做记录在本次会话中保留;保存重开保留蒙版,不保存逐笔命令历史。
- **对比预览:**新的原图和重绘结果保存在服务器内存中,不再生成对比临时图片。默认缓存上限为 512 MiB,同时最多保留 512 批对比,可在启动 ComfyUI 前设置 `ADACHI_COMPARE_MEMORY_MIB` 为 64~4096 调整字节上限。内存淘汰或服务器重启后,旧对比和下载引用会失效,重新运行工作流即可恢复;单批超过上限会提示错误。旧版磁盘预览文件存在时仍可读取,其过期清理也继续保留。采用到编辑器的图片另存于 `input/adachi-inpaint/` 或 `input/adachi-inpaint-layers/`。
- **功能范围:**这是独立实现的 WebUI 风格工具,不保证与 WebUI 像素、脚本和扩展行为完全一致;额外软边羽化并非 WebUI 的完整 Soft Inpainting 算法。
<details>
<summary>采样、填充与批次细节</summary>
- 采样宽高使用 8 的倍数。padding、模糊和蒙版扩张以原图像素计量;模糊/扩张会改变有效蒙版边界,有效蒙版为零的像素保持不变。
- `mask_mode=outside` 会先反转涂抹区域,再进行蒙版处理。
- `resize_mode` 控制整图采样的调整方式;仅蒙版区域使用保留完整蒙版的比例变换,也适用于蒙版碰到图像边缘的情况。
- 填充模式包括**原图像素、周边颜色填充、潜空间噪声、空白潜空间**。潜空间模式属于一体化重绘节点;模块化 Prepare 只处理图像和蒙版。
- 空蒙版或 `denoise=0` 会跳过 VAE 和采样。`batch_size` 表示每张原图生成的变体数,不是 ComfyUI 队列次数。
- 模块化多原图批次需按原图分组变体,例如 `A,A,B,B`,而非交错的 `A,B,A,B`。
- 可选 Refiner 使用 `refiner_model` 与 `refiner_switch`;`0.8` 表示最后 20% 步骤使用 Refiner,建议提供兼容的专用文本条件。设为 `1` 只用基础模型,`0` 只用 Refiner。
- 输入限制为单文件 64 MiB、单图 3200 万像素、单边 16384 像素。大图绘制与 PNG 编码速度仍取决于浏览器内存和硬件。
</details>
## 排错与开发
**更新后还是旧界面?** 完整重启 ComfyUI,再强制刷新网页。Python 后端修改不能仅靠刷新网页加载。
**自动二选一提示没有有效输入?** 检查真正的上游源节点和对应 Set,不能只看 Get。将二选一放在受控分组之外。若其他集成仍会丢掉子图输出,可以先把 CLIP 编码器放到子图外。反馈时请附上工作流、插件/前端版本和完整错误;当前错误会显示前端、工作流与执行输入的状态标记。
**打开别人的工作流后图片丢失?** 按相对路径补齐原图,或请对方开启原图嵌入后再保存。临时对比预览失效则需要重新生成。
运行 CPU 回归测试:
```bash
python -m unittest discover -s tests -v
node --experimental-default-type=module tests/test_exclusive_routes.mjs
node --experimental-default-type=module tests/test_compare_history.mjs
```
已测试配置和范围见[验证记录](VERIFICATION.md),版本变化见[更新日志](CHANGELOG.md),恢复旧版本见[回滚说明](ROLLBACK.md)。模型家族和实际画质测试并非全覆盖。
## 版本更新摘要
| 版本 | 主要功能和修复 |
| --- | --- |
| 2.1.1 | 精简对比适应按钮、将重绘画布选项移至生成按钮下方,并把彩色画笔并入左下角工具栏。 |
| 2.1.0 | 彩色画笔图层、裁切合并进度、工作台对比缩放、常用参数上移、提示词折叠及 GPL-3.0-only 许可证。 |
| 2.0.1 | 弱网重试与取消、预览资源清理、回退竞态、大蒙版内存优化和异常工作流校验。 |
| 2.0.0 | 独立黑白工作台、工作流发现、生成中编辑、进度、结果恢复和统一模板。 |
| 1.0.1/1.0.0/0.9 | 稳定版、预览重试与手动刷新、内存对比、真实上传进度。 |
| 0.8 | 裁切采用和尺寸同步、下载最新结果、不同尺寸上传刷新。 |
| 0.7 | 替换图片保留蒙版、下载原图、图钉保持当前视图。 |
| 0.6 | 裁切融合、选区和尺寸同步、右键预览/重选与草稿恢复;魔棒和快速选择后来已移除。 |
| 0.5 | 组忽略预设、自动二选一、保留蒙版、单帧 5D 图像 VAE 和画布性能修复。 |
| 0.4/0.3/0.2 | 内联控件与纵向调整、图片对比/采用、临时橡皮擦及中英文节点界面。 |
逐版本的详细修复见[完整更新日志](CHANGELOG.md)。
## 致谢与许可证
由 **Adachi / 安达** 制作,当前项目采用 [GNU GPL-3.0-only 许可证](LICENSE)。GPL 允许商业使用;分发受许可程序或其修改版时,应遵守 GPL-3.0 的源码与许可要求。v2.0.1 及更早版本按 MIT 发布,已有接收者仍保留此前获得的权限;原版权和许可通知保留在 [LICENSES](LICENSES/MIT-previous-releases.txt),当前分支改用 GPL 不会撤销先前的 MIT 授权。
基于 [ComfyUI](https://github.com/Comfy-Org/ComfyUI) 构建,重绘交互参考 WebUI,鼠标划线对比体验参考 [rgthree-comfy](https://github.com/rgthree/rgthree-comfy)。代码由本仓库独立实现,这些项目不作为捆绑依赖。
欢迎通过 [Issues](https://github.com/adachi0130/Adachi-Inpaint-Workbench/issues) 反馈问题、分享工作流用法。
+74
View File
@@ -0,0 +1,74 @@
# Git versions and rollback
The stability fixes in 2.0.1 start from `local-before-stability-20261110` (the published `v2.0.0` commit). A complete copied-installation ZIP is stored in `ComfyUI/user/adachi-workbench-backups/` with prefix `Adachi-Inpaint-Workbench-before-stability-20261110-`. Restore that backup or switch a clean source checkout to the baseline tag, then reinstall, restart ComfyUI and hard-refresh. The published 2.0.0 archive remains unchanged.
The current version is **v2.0.1**. Git history lives in the source repository. The installed custom-node folder may be a ZIP/file copy without its own `.git`; do not run rollback commands in ComfyUI's parent repository.
Before installing, the installer saves the previous plugin directory under `ComfyUI/user/adachi-workbench-backups/`. Restore that ZIP to roll back a copied installation. For a Git clone, use the tags below.
| Tag | Contents |
|---|---|
| `v2.0.1` | Stability, cancellation, recovery and dense-mask memory fixes |
| `v2.0.0` | Standalone workbench, workflow discovery, cloud recovery, localization and unified template |
| `v1.0.1` | Previous published version with comparison retry |
| `v1.0.0` | First stable release with current bilingual crop instructions |
| `local-before-v1.0.0` / `v0.9.0` | Source before stable-release documentation and metadata |
| `v0.9.0` | In-memory comparison previews and real upload progress |
| `local-before-v0.9.0` / `v0.8.2` | Source before memory previews and upload progress |
| `v0.8.2` | Refresh different-sized uploads and synchronize inpaint dimensions |
| `local-before-v0.8.2` / `v0.8.1` | Source before upload and dimension synchronization fixes |
| `v0.8.1` | Expire old comparison previews and download the latest result |
| `local-before-v0.8.1` / `v0.8.0` | Source before comparison-preview cleanup and latest-result download |
| `v0.6.7` | Hold right mouse to preview the original before merging; contextual hover help |
| `local-before-v0.6.7` | Source before original-layer preview and expanded hover instructions |
| `v0.6.6` | Right-click releases crops; move mask Undo beside Merge |
| `local-before-v0.6.6` | Source before right-click reselection and inline Undo |
| `v0.6.5` | Change crop dimensions while selected, automatically return to selection |
| `local-before-v0.6.5` | Source before editable locked-crop dimensions |
| `v0.6.4` | Black crop frame, automatic dimensions and comparison-only result adoption |
| `local-before-v0.6.4` | Source before the comparison-adoption update, including v0.6.3 draft preflight fixes |
| `v0.6.2` | Draft recovery, collapsible controls, retired selection tools and revised crop behavior |
| `local-before-v0.6.2` | Exact baseline before the combined draft/fold and crop updates |
| `v0.6.0` | Merged crop/blend editor, magic wand and quick selection |
| `local-before-v0.6.0` | Exact source baseline before the merged editor (includes bilingual README) |
| `v0.5.13` | Restore dropped enabled inputs through saved subgraph output routes |
| `v0.5.12` / `local-before-v0.5.13` | Trace Get/Set source states server-side before prompt validation and caching |
| `v0.5.11` / `local-before-v0.5.12` | Resolve exclusive selection from actual submitted connections despite stale frontend flags |
| `v0.5.10` / `local-before-v0.5.11` | 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 |
To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach v2.0.0
```
Return to this version with:
```powershell
git switch --detach v2.0.1
```
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`; use the installer backup for a copied installation.
Local workbench UI / network preview (2026-11-09) baseline: `local-before-workspace-ui-network-20261109` (`392149f`). To undo this local work on a clean checkout, use `git switch --detach local-before-workspace-ui-network-20261109`, reinstall those files, restart ComfyUI and hard-refresh. The installed directory also has a full ZIP backup in `user/adachi-workbench-backups/` whose name starts with `Adachi-Inpaint-Workbench-before-workspace-ui-network-20261109-`.
The editable-generation / execution-stage update starts at `local-before-workspace-run-progress-20261109` (`8ec5281`). A copied-installation ZIP backup named `Adachi-Inpaint-Workbench-before-run-progress-20261109-*.zip` is in the same backup directory. This update changes frontend files only; hard-refresh after restoring them.
Generic routing / resizable workspace baseline: `local-before-generic-workbench-20261109` (`02719c3`). The copied-installation backup starts with `Adachi-Inpaint-Workbench-before-generic-layout-20261109-` in `user/adachi-workbench-backups/`. This update changes frontend files; hard-refresh after restoring them.
+147 -20
View File
@@ -1,26 +1,153 @@
# 验证记录
# Verification record
验证环境:Windows、本机 ComfyUI 0.19.3、ComfyUI frontend 1.42.11、PyTorch 2.8.0、Pillow 11.0.0、Chrome 155,使用独立的 8190 端口、CPU、测试输入输出目录和内存数据库喵。
## 2026-11-12 · model compatibility and documentation
- 后端 16 项 CPU 测试通过,覆盖两种范围、边缘蒙版、反相、增长、羽化、周边颜色填充、批量映射、原像素保护、空蒙版、denoise=0、潜空间噪声/置零、专用 inpaint conditioning、不同 VAE 压缩比和 Refiner 分段喵。
- 浏览器 12 组交互测试通过,覆盖真实鼠标绘画、右键擦除、矩形/套索、撤销重做、导入持久化、半压力硬笔刷、活动形状保存、缩放坐标、1600/768/390 像素窗口布局喵。
- 经典节点与 Nodes 2.0 均完成真实画布拖画、工作流序列化、运行参数序列化与重新载入测试,蒙版像素一致且正确触发工作流修改状态,无插件 JavaScript 错误喵。
- 实际 ComfyUI API 队列执行了两种范围的载入→预处理→回贴→保存图像工作流,输出尺寸正确,有效蒙版外 RGB 像素逐值相等喵。
- 两份示例由真实节点生成、重新加载并构造执行参数,节点和连线有效,模型文件名为用户选择占位符喵。
- 安装脚本在临时目录完成首次安装、再次安装及旧版备份测试喵。
- 0.1.1 在经典节点和 Nodes 2.0 用真实鼠标拖动底部高度条,画布增高 160px、缩小 80px,保存并重开后尺寸和蒙版均保留喵。
- 0.1.2 用真实浏览器 Ctrl+滚轮测试笔径增减、1–512px 上下限、滑条数值同步和普通滚轮缩放,确认四个滑条位于画布下方;浏览器共 14 组交互验证通过喵。
- 0.2.0 新增 Alt 临时擦除和图像对比节点,后端 18 项测试通过;真实鼠标/键盘操作确认松开 Alt 恢复原工具喵。
- 0.2.0 在经典节点与 Nodes 2.0 的实际 API 队列验证左右像素、鼠标分割线、滚轮缩放、Ctrl+滚轮笔径、批量结果选择、多编辑器上游定位、点击回填、旧蒙版清空和保存后重新打开,无插件脚本错误喵。
- 两种节点模式中的图片滚轮事件均不会误触发整张工作流缩放,回填结果保存在 input 目录并可嵌入工作流喵。
- A real SD 1.5 checkpoint VAE was loaded on CPU and tested through the plugin: 64×64 encode, mock sampling, decode and masked compositing succeeded with a [1,4,8,8] latent, finite output and exact protected pixels. This is not a complete diffusion-generation test.
- The installed 2.0.1 frontend was captured in an isolated ComfyUI instance. Screenshots use a synthetic image and a CPU ImageInvert result to demonstrate the interface, not an AI-generated inpaint.
采样路径还使用本机 ComfyUI 的真实 common_ksampler/KSampler 函数进行了噪声调度衔接检查;尚未加载实际 checkpoint 测试 GPU 生成质量或所有模型家族,不能将 CPU/接口测试视为模型效果验证喵。
## 2.0.1 · stability audit (2026-11-10)
实现参考资料:
- 68 Python regression tests and all 20 JavaScript test files cover new cancellation, stalled decoding/upload, corrupt-cache retry, dense/sparse/edge masks, bounded cache metadata, partial rollback failures, concurrent completed edits and malformed input mappings.
- Real ComfyUI browser checks pass on frontends 1.42.11 and 1.52.7 in classic and Nodes 2.0 rendering. Each configuration performs 20 mount/close cycles with zero remaining workspace API listeners, delayed initialization disposal, a delayed submit response across close/reopen, and recovery with execution notifications suppressed.
- Actual CPU image queues cover normal adoption/undo and crop-origin validation, crop/mask retention, layer adoption, automatic return to editing, protected exterior pixels and blend clear/undo. Invalid source replacement retains the previous usable image. Model sampling algorithms are unchanged; no new model-quality claim is based on these CPU queues.
- Browser resource checks recover a corrupt PNG, reload 25 times and overlap 30 cancellation attempts. At settled checkpoints exactly two preview object URLs remain, destruction releases all tracked URLs, and no unhandled promise rejection is observed. Network fault tests exhaust immediate retries, then recover on reconnect with byte progress and no duplicate history entries.
- Isolated CPU benchmark: a full 2048×4096 mask, four threads, four iterations per process averages 203.4 ms before and 14.6 ms after. Additional measured process peak working set is 521.0 MiB before versus 9.2 MiB after. This measures mask-bound computation only, not overall generation time or workflow frame rate.
- Scope: local fault injection is not a long-duration cloud soak test, and cannot establish compatibility with every third-party plugin/model combination. The public 2.0.0 Registry version remains flagged pending external review; this local audit does not establish Registry approval.
- [ComfyUI 前端扩展接口](https://docs.comfy.org/custom-nodes/js/javascript_overview)
- [ComfyUI widget 序列化说明](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/WIDGET_SERIALIZATION.md)
- [WebUI 重绘预处理语义](https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/master/modules/processing.py)
- [rgthree 图像对比交互参考](https://github.com/rgthree/rgthree-comfy/blob/main/py/image_comparer.py)
## 2.0.0 · 2026-11-09
插件代码为独立实现,发布许可见 LICENSE,未附带上述项目源码喵。
- All 63 Python regression tests and 19 JavaScript test files pass. Frontend modules pass syntax checks; locale JSON, release metadata and local links in both READMEs validate.
- The single official template imports into a real ComfyUI frontend. Both presets compile through the native graph serializer and pass backend validation with no missing links. Text-to-image excludes the uploaded-image branch; inpainting excludes the latent-generation branch. The three exclusive selectors consistently select the only enabled input.
- Both template presets complete actual 512×512, two-step WAI generation without LoRA, using newly authored neutral prompts and a synthetic input. Inpainting changes the nonempty masked region while pixels outside the protected region remain exact. The distributed template keeps its checkpoint placeholder and contains no model names, local paths, source images or personal prompts.
- Real frontend settings expose the locale at `comfyAPI.app.app.ui.settings`. The translation reader supports that path, older frontend shapes and the English fallback; tests cover interpolation, context keys and current execution messages.
- GitHub publication, Registry upload and Registry approval are checked separately. See the Registry review notes for public scan findings; local testing does not constitute Registry security approval.
- Local white workspace preview: real `gpt test` WAI v160 generation with no LoRA, 512×512, two sampler steps and a nonempty mask succeeds. Protected pixels remain exact. The UI run is also verified: immediate opening, background backend validation, invalid-model rejection without queueing, result preview without adopting, before/after, explicit adoption, mask-preserving rollback and source selection. Classic and Nodes 2.0 browser tests cover actual mouse painting, comparison, clean close and reopen; 768/390 layouts have no horizontal overflow. Source history tests include crop/layer/fusion-mask restoration. No remote cloud or Anima model inference is claimed by these checks.
- 1.0.0: all 54 CPU backend tests and all seven JavaScript regression suites pass; every frontend module passes syntax checking and all bundled examples parse as JSON. The release updates documentation and metadata on top of 0.9.0; no full model generation was run for this release.
- 0.9.0: 54 backend CPU tests cover in-memory PNG pixels, atomic batch storage, LRU eviction, cached execution invalidation and oversized batch rejection. Actual CPU image queues in frontend 1.52.7 and both node renderers verify comparison history, download bytes, ordinary and crop adoption, mask retention, layer blending, workflow reload and legacy disk-backed references; no new comparison temp files are created. Real Chrome network throttling confirms intermediate upload percentages, loading indication, user header, failure and concurrent upload behavior in both renderers. Proxy paths and auth fallback were separately tested; the user's actual cloud environment and full model inference were not tested.
- 0.8.2: Chrome interaction with an isolated ComfyUI and frontend 1.52.7 passes in classic nodes and Nodes 2.0. Actual uploads confirm different-size source pixels and canvas display update, same-size masks are preserved, connected inpaint dimensions follow ordinary uploads and crop selection, switching back restores image dimensions, a newly connected inpaint node receives the size, and reopening a saved workflow preserves manually saved dimensions. No model inference was run.
- 0.8.1: 51 backend CPU tests pass, including expiry, preservation of unrelated/active/input files and comparison cache renewal. On an isolated local ComfyUI with frontend 1.52.7, actual lightweight image queues in classic nodes and Nodes 2.0 verify two comparison results, selection of an older result, downloading the newest PNG byte-for-byte, and cached notification deduplication. Browser checks also cover the Chinese tooltip, absent result and an expired-preview error. No full model generation was performed.
- 0.6.7: real Chrome mouse interactions on frontend 1.52.7 pass in classic nodes and Nodes 2.0. Display pixel checks verify hold-right original preview and release restoration, including release outside the picture, pointer cancellation and window blur. Preview overrides mask-only display temporarily, leaves serialized image/mask/crop state unchanged, and does not affect composite export pixels. After Merge, right-click still releases the selection; normal-mode right-click does not erase. Bottom controls have hover instructions and canvas help follows the editing stage. These checks use solid-color image fixtures, not model generation.
- 0.6.6: real mouse checks in classic nodes and Nodes 2.0 on frontend 1.52.7 verify right-click release without erasing, right-click while unselected, left-click relocking, preserved adopted pixels after merge, Alt temporary erase in both modes, and a functional live Undo button directly after Merge. The old reselect button and inpaint-panel Undo are absent. Canvas context menus are stopped from bubbling to the outer node. Frontend syntax and diff whitespace checks pass.
- 0.6.5: classic and Nodes 2.0 browser checks on frontend 1.52.7 verify changing a locked preset, pointer-following replacement selection, automatic inpaint dimensions after relocking, preservation of an adopted patch by merging, retention of the old selection/resolution after an upload failure, and reselection through swap/custom controls. Frontend syntax and diff whitespace checks pass.
- 0.6.4 comparison-adoption update: 48 backend tests pass, including removal of the result socket, ignoring obsolete feedback links, and generating-region metadata on comparison previews. Resolution-sync checks cover multiple connected targets, linked-input protection and source-size bounds. Real CPU comparison queues and browser interactions on isolated port 8196 with frontend 1.52.7 pass in classic nodes and Nodes 2.0: black dashed frame, automatic inpaint dimensions, no automatic stitching, manual crop adoption with retained repaint mask, independent fusion editing, reload, stale-result rejection, normal-mode mask preservation, and migration of the old wired example. Existing draft preflight/storage, comparison history, geometry and frontend syntax checks also pass. No complete model generation is claimed by these lightweight image probes.
- 0.6.3: reproduced the sticky native draft failure on real frontend builds 1.42.11 and 1.52.7: after a 12 Mi-character draft fails, even a two-character draft returns false until the page reloads. Isolated lightweight API fixtures (no model queues or production writes) verify oversized workflow loading before configuration, exact source byte hashes, repaint mask pixels, stale named-snapshot cleanup, repeated mode changes and subsequent successful native draft writes in classic nodes and Nodes 2.0 on both builds. Unit checks additionally cover nested subgraph sources, named-only snapshots, fitting/blocked storage, failed uploads and unchanged masks/layers. This does not claim inspection of the user's live browser console or a complete model generation.
- 0.6.1 fixes (included in 0.6.2): reproduced the exact browser “failed to save workflow draft” toast on frontend 1.42.11 with an approximately 10-million-character embedded source and a real QuotaExceededError. The capacity guard now converts only a source that does not fit, uploading it before clearing embedded data. SHA-256 checks confirm the input PNG bytes are identical; repaint/fusion masks, layers and undo commands survive. Failed uploads preserve both embedding and mode, existing browser storage is untouched, fitting sources remain embedded and disabled workflow persistence is respected.
- Combined 0.6.2: the quota/fold browser regression passes in classic nodes and Nodes 2.0 on frontends 1.42.11 and 1.52.7 (1.52.7 also tested after the concurrent crop/tool changes). The collapsed tools hide painting controls and mask/fill options while all seven action buttons remain visible and operational. Heights update, open/closed states survive workflow reload, and 1440/768/390 layouts pass. Real CPU feedback queues use image inversion rather than model inference; no complete Anima generation is claimed. All 47 backend tests and JavaScript tests for draft guarding, crop geometry, exclusive routing and comparison history pass.
- 0.6.2: all 47 backend tests, crop geometry checks and existing draft-guard checks pass; all frontend modules pass syntax checks. Real browser checks on an isolated 8194 CPU service with frontend 1.52.7 pass in classic nodes and Nodes 2.0: locked crops start empty, manual strokes paint normally, fill/rectangle/brush stay within the crop, undo/redo and reload preserve the mask, a fresh region clears previous strokes, ordinary mode remains unrestricted, and the toolbar contains no automatic-selection tools. Six presets include both 512 and 2048 endpoints and use only 64-aligned dimensions.
- 0.6.0: 47 CPU backend tests pass, including exact crop/mask slicing, soft fusion masks, batch composites, untouched outside pixels, content-addressed layer files, invalid bounds/result sizes, normal-mode ignored results and acyclic feedback normalization. Geometry, magic-wand/quick-selection, exclusive routing and comparison-history JavaScript checks also pass. All frontend modules pass syntax checks; example/localization JSON and project metadata parse successfully.
- 0.6.0: real Chrome interaction and real CPU queues on isolated port 8191 with ComfyUI 0.19.3 and frontend 1.52.7 pass in classic nodes and Nodes 2.0. Tests cover a visible Inline Mask → ImageInvert → Inline Mask loop with full-image saving, normal-mode result ignoring, magic-wand add/subtract, quick-selection drag/subtract, independent repaint/fusion masks, soft-brush undo/redo, fixed-region repeat generation, saved-workflow restoration, merge/reselect, stale-job rejection, Ctrl/Shift wheel gestures, pinned graph scrolling and 1440/768/390 viewport layouts. No plugin script errors were observed. These are image-processing probes, not a complete Anima generation.
- 0.6.0: the connected WebUI panel mounts the existing live painting controls, source-size filling uses locked crop dimensions, Escape closes the selection menu without reselecting, and the internal layer sink is hidden from the classic node menu. The old example loads with existing image/mask wires intact; the new eight-node crop/blend example is generated and reloaded through ComfyUI.
- 0.5.13: the remote history was read without modifying its workflow or queue. Three actual failed request snapshots contained selector 532 with a selected flag but no data input; local normalization restores its saved tag-subgraph connection to `349:412`, leaving selector 533's `16` source intact. Two real local CPU queue probes reproduce the missing-input shape and subgraph mapping with lightweight image sources, verify both selector outputs and upstream changes, and avoid model generation. All 39 backend unit tests pass, including Get/Set recovery, flattened subgraph output slots and refusal to reconnect absent or mismatched source types. The remote's complete Anima generation was not executed by these tests.
- 0.5.12: all 37 CPU unit tests pass. The supplied remote workflow was used as request metadata with lightweight red/blue image probes replacing model generation: positive selector 532 chooses B, negative 533 chooses A, all four Gets remain enabled. Ten real queue cases cover reverse Set/source states, switching back, encoder-only and Set-only bypass, unchanged-workflow preservation, cache reuse, and both-enabled/both-ignored errors. No complete Anima model generation is claimed by these routing probes. Sixteen native KJ Get/Set browser queue cases pass on frontend 1.52.7 in both renderers, and all 15 metadata-free stale-state API queue cases still pass.
- 0.5.11: all 32 backend CPU tests pass, including stale branch flags with either single submitted input, strict empty/ambiguous input errors, both-connected passthrough selection and dynamic subgraph node lookup. Fifteen real CPU queue cases verify single-branch A/B output pixels for flags -1 through 3, both-connected A/B selection, and invalid cases. Sixteen real KJ Get/Set queue cases on frontend 1.52.7 cover both renderers, source/Set/Get ignore and mute states, reverse switching, both-invalid states and workflow reload.
Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.8.0, Pillow 11.0.0, Chrome 155, using a separate port 8190 CPU service with its own input/output directories and an in-memory database.
- 16 backend CPU tests pass, covering both sampling areas, edge masks, inversion, growth, feathering, surrounding-colour fill, batch mapping, source-pixel protection, empty masks, `denoise=0`, latent noise/zero modes, dedicated inpaint conditioning, different VAE compression ratios and Refiner staging.
- 12 browser interaction tests pass, covering real mouse painting, right-button erase, rectangle/lasso, undo/redo, import persistence, half-pressure hard brush, active-shape saving, zoom coordinates and 1600/768/390 pixel window layouts.
- Painting on the real canvas, workflow serialization, executed-parameter serialization and reload were verified in both classic nodes and Nodes 2.0: mask pixels match and the workflow correctly enters the modified state, with no plugin JavaScript errors.
- The real ComfyUI API queue ran load → prepare → composite → save-image workflows in both sampling areas, with correct output sizes and RGB pixels outside the effective mask equal value-by-value.
- Both example workflows were generated by the real nodes, reloaded and used to build executed parameters; nodes and links are valid and model filenames are user-selectable placeholders.
- The installer was tested in a temp directory for first install, repeat install and previous-version backup.
- 0.1.1: in classic nodes and Nodes 2.0 the bottom height bar was dragged with a real mouse, growing the canvas by 160 px and shrinking it by 80 px; size and mask both survived save and reopen.
- 0.1.2: real-browser Ctrl+wheel tests covered brush size growth and shrink, the 1–512 px limits, slider value sync and plain-wheel zoom, confirming the four sliders sit below the canvas; 14 browser interaction checks passed in total.
- 0.2.0: added Alt temporary erase and the image comparison node; 18 backend tests pass. Real mouse/keyboard use confirms releasing Alt restores the previous tool.
- 0.2.0: the real API queue in classic nodes and Nodes 2.0 verified left/right pixels, the mouse divider, wheel zoom, Ctrl+wheel brush size, batch result selection, upstream editor discovery with several candidates, click-to-adopt, old mask clearing and reopen after save, with no plugin script errors.
- Picture wheel events in both node modes never trigger whole-workflow zoom by mistake, and adopted results are stored in the input directory and can be embedded in the workflow.
- 0.3.0: in classic nodes and Nodes 2.0 the bottom inline parameters were verified to drive the real sampling node, width/height on separate rows, source-size filling, no repeated growth on workflow reopen, disconnect restoring the original parameters, and the original parameter rows actually hidden in Nodes 2.0.
- 0.3.0: wheel events over the toolbar and parameter area pass through to the outer canvas, a continuous outer wheel gesture does not change image zoom when it enters the picture, and wheel events inside the picture after stopping zoom only the picture.
- 0.3.0: the comparison node was measured with the result on the left and the original on the right, showing the full result on exit; the divider is a single physical pixel, the round handle, corner labels and bottom caption were removed, and adoption plus batch selection still pass.
- 0.3.0: 15 brush browser interaction checks pass, vertical stretching and save/restore pass in both rendering modes, and the two updated example workflows pass link and executed-parameter validation.
- 0.4.0: in classic nodes and Nodes 2.0 the inline canvas was verified to keep only the load button with the original painting DOM controls moved into the WebUI node; real mouse painting can then be undone from the inpaint node, the tools and sliders drive the original canvas and the sampling parameters correctly reach the execution request.
- 0.4.0: canvas margins shrinking to the image ratio, thin height-bar stretching, size retention across save/reopen, source-size filling, disconnect/reconnect, deleting and recreating the inpaint node, and toolbar-area wheel events continuing to drive the outer canvas were all verified.
- 0.4.0: the comparison node no longer has a top toolbar, with result selection, dimensions and fit moved to the bottom; left/right comparison in the real API queue, full result on exit, batch selection and result adoption all pass.
- 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.
- 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:
- [ComfyUI frontend extension APIs](https://docs.comfy.org/custom-nodes/js/javascript_overview)
- [ComfyUI widget serialization notes](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/WIDGET_SERIALIZATION.md)
- [WebUI inpainting pre-processing semantics](https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/master/modules/processing.py)
- [rgthree image comparison interaction reference](https://github.com/rgthree/rgthree-comfy/blob/main/py/image_comparer.py)
The plugin code is an independent implementation; see LICENSE for the release licence. No source from the projects above is bundled.
## 2026-11-09 · local workspace UI and cloud recovery
- 58 Python tests and all JavaScript test files pass, including new protocol, conditioning-route, preview stream and result-recovery cases.
- Real `gpt test` WAI profile without LoRA validates against ComfyUI: positive #521, negative #520, model #518. The original graph modes and links are restored after compilation. Missing models are blocked before queueing.
- Real CPU image processing verifies manual adoption, unchanged mask pixels, undo/original restoration and result-count reset. Generation defaults to sliding comparison without adopting.
- Chrome interaction checks verify remembered light/dark themes, icon tooltips, one before/after toggle, enabled/missing controllers and dynamically added presets.
- Classic and Nodes 2.0 crop controls pass at 1440, 768 and 390px in both themes, including wrapped history labels, custom dimensions, painting and dimension synchronization.
- Cloud failure simulation drops `executed` alone, then all execution notifications. Exact-task history recovery succeeds in both the node and workspace. Three image-load attempts are exhausted before restoring connectivity; byte progress is visible and preview recovery succeeds. Replayed output does not increase the result count.
- Existing source workflows and images are not modified. Runtime files are compared with the preceding Git checkpoint before installation; the installed package is backed up before copying changes.
## 2026-11-09 · editable generation and execution stages
- An actual WAI 512px / six-step generation reports node stages and sampler fractions. A real mouse stroke was added during sampling; the queued mask remained byte-identical to its submitted snapshot, and both the source and new mask edits survived result delivery.
- Crop adoption was checked independently with a real processed image and crop-origin metadata. It returns to the editor with the locked region, result layer and repaint mask intact.
- Progress rendering fits above workflow settings at 1440, 768 and 390px in both themes. Unit checks cover prompt isolation, early events before the HTTP response, cached nodes, reconnects and late progress packets after completion.
- Both local Anima profiles compile their subgraph correctly and fail validation only for the three absent model components. The installed backend and model inventory confirm the missing checkpoint, text encoder and VAE. Model UI now lists filenames and target folders; missing-component checks never infer absent files from a network failure.
- Legacy frontend scalar/null `executing` messages and a submit HTTP response deliberately delayed until after task completion are covered, so early completion cannot cause a false error or leave the workbench busy.
## 2026-11-09 · generic workflow routing and resizable workspace
- Real divider drag, keyboard resize, persisted width and reset pass; 768px/390px layouts keep stacked panels and the English brand without overflow.
- All three existing example presets compile through Use Everywhere’s public native serialization API. MODEL and positive/negative CLIP branches retain their original LoRA routes in inherited mode. Bypassed routing is enabled only for the selected bypassed workbench group, and all editable modes are restored. Unique exclusive switches are resolved in the separate execution graph before backend metadata normalization.
- A copy with every root node ID changed and all preset titles replaced with unrelated names still discovers and validates the correct components.
- A newly built two-pipeline workflow validates with SD1.5, selects the connected editor/result instead of an unrelated first loader, and preserves ModelSamplingDiscrete, CLIPSetLastLayer, two-encoder conditioning, negative conditioning processing, image resizing and result postprocessing. A real WAI generation in that workflow confirms the final ImageInvert is executed by checking outside-mask pixels.
- A custom execution-only LoRA validates without any editable LoRA nodes. Pure tests cover independent IDs/filenames, ControlNet positive/negative slots, SDXL text fields, inherited/custom split CLIP paths, untouched unrelated branches, Vue proxy JSON copying, explicit ambiguity, group activation safety and subgraph result recovery.
+28 -2
View File
@@ -1,6 +1,32 @@
"""Adachi Inline Inpaint custom-node package for ComfyUI."""
"""Adachi-Inpaint-Workbench custom-node package for ComfyUI."""
from .backend import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
from .backend import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, normalize_exclusive_prompt
from .preview_cleanup import start_preview_cleanup
from .preview_store import PREVIEWS
from .workspace_execution import register_workspace_routes
from aiohttp import web
from server import PromptServer
import re
if not getattr(PromptServer.instance, "_adachi_exclusive_normalizer", False):
PromptServer.instance.add_on_prompt_handler(normalize_exclusive_prompt)
PromptServer.instance._adachi_exclusive_normalizer = True
if not getattr(PromptServer.instance, "_adachi_preview_route", False):
@PromptServer.instance.routes.get("/adachi-inpaint/compare/{filename}")
async def adachi_comparison_preview(request):
name = request.match_info["filename"]
if not re.fullmatch(r"[A-Za-z0-9_-]{32}\.png", name):
raise web.HTTPNotFound()
data = PREVIEWS.get(name[:-4])
if data is None:
raise web.HTTPNotFound(text="Comparison preview expired; run the workflow again.")
return web.Response(body=data, content_type="image/png", headers={"Cache-Control": "no-store"})
PromptServer.instance._adachi_preview_route = True
start_preview_cleanup()
register_workspace_routes(PromptServer.instance)
WEB_DIRECTORY = "./web"
+582 -76
View File
@@ -1,4 +1,6 @@
"""Portable inline inpainting nodes for ComfyUI (MIT licensed).
"""Portable inline inpainting nodes for ComfyUI (GPL-3.0-only).
SPDX-License-Identifier: GPL-3.0-only
All image and crop operations below are original implementations. No code from
AUTOMATIC1111, Forge or their AGPL components is included.
@@ -10,9 +12,11 @@ import base64
import binascii
import hashlib
import io
import json
import math
from pathlib import Path
import re
import tempfile
import warnings
import numpy as np
@@ -20,8 +24,13 @@ from PIL import Image, ImageFilter, ImageOps, UnidentifiedImageError
import torch
import torch.nn.functional as F
if __package__:
from .preview_store import PREVIEWS
else: # Standalone CPU tests load this module directly.
from preview_store import PREVIEWS
CATEGORY = "安达工作台 / Adachi Workbench"
CATEGORY = "Adachi-Inpaint-Workbench"
MAX_IMAGE_PIXELS = 32_000_000
MAX_IMAGE_BYTES = 64 * 1024 * 1024
MAX_DATA_URL_CHARS = 4 * ((MAX_IMAGE_BYTES + 2) // 3) + 128
@@ -93,7 +102,7 @@ def _image_tensor(image):
value = torch.as_tensor(image)
if value.ndim != 4 or value.shape[-1] not in (3, 4) or min(value.shape[:3]) < 1:
raise ValueError("IMAGE must have shape [batch, height, width, 3 or 4].")
return torch.nan_to_num(value[..., :3].float(), nan=0.0, posinf=1.0, neginf=0.0).clamp(0.0, 1.0)
return torch.nan_to_num(value[..., :3].float(), nan=0.0, posinf=1.0, neginf=0.0).clamp_(0.0, 1.0)
def _mask_tensor(mask, images):
@@ -110,7 +119,7 @@ def _mask_tensor(mask, images):
value = value.expand(images.shape[0], -1, -1)
if value.shape[0] != images.shape[0]:
raise ValueError("Mask batch must contain one mask or match the image batch.")
value = torch.nan_to_num(value, nan=0.0, posinf=1.0, neginf=0.0).clamp(0, 1)
value = torch.nan_to_num(value, nan=0.0, posinf=1.0, neginf=0.0).clamp_(0, 1)
if value.shape[-2:] != images.shape[1:3]:
value = F.interpolate(value[:, None], size=images.shape[1:3], mode="bilinear", align_corners=False)[:, 0]
return value
@@ -136,11 +145,15 @@ def _feather(mask, grow, blur):
def _bbox(mask, padding, target_ratio):
height, width = mask.shape
nonzero = torch.nonzero(mask > 0, as_tuple=False)
if not len(nonzero):
occupied = mask > 0
rows = torch.where(occupied.any(dim=1))[0]
if not len(rows):
return (0, 0, width, height)
y0, x0 = nonzero.amin(0).tolist()
y1, x1 = (nonzero.amax(0) + 1).tolist()
# Dense masks previously allocated two int64 coordinates per painted pixel
# (512 MB for a 32 MP mask). Only row/column occupancy is needed for bounds.
columns = torch.where(occupied.any(dim=0))[0]
y0, y1 = int(rows[0]), int(rows[-1]) + 1
x0, x1 = int(columns[0]), int(columns[-1]) + 1
x0, y0 = max(0, x0 - padding), max(0, y0 - padding)
x1, y1 = min(width, x1 + padding), min(height, y1 + padding)
crop_width, crop_height = x1 - x0, y1 - y0
@@ -289,17 +302,17 @@ def _composite(generated, context):
def _prepare_inputs():
return {
"image": ("IMAGE", {"tooltip": "原图 / Original image; output is restored to this exact size."}),
"mask": ("MASK", {"tooltip": "白色重绘,黑色保护 / White repaints; black preserves."}),
"area": (["whole_image", "only_masked"], {"default": "only_masked", "tooltip": "whole_image:整张原图缩放采样;only_masked:蒙版包围框加边距放大采样,再贴回原图 / Whole image or mask bounding-box inpainting."}),
"width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "采样画布宽度;最终输出仍为原图尺寸 / Sampling canvas width, independent of final image size."}),
"height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "采样画布高度 / Sampling canvas height."}),
"padding": ("INT", {"default": 32, "min": 0, "max": 2048, "step": 1, "tooltip": "仅蒙版区域:原图像素单位的上下文边距 / Mask-only context padding in original pixels."}),
"mask_blur": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 128.0, "step": 0.5, "tooltip": "原图像素单位的高斯羽化半径 / Gaussian mask feathering in original pixels."}),
"mask_grow": ("INT", {"default": 0, "min": -128, "max": 128, "step": 1, "tooltip": "正数扩张,负数收缩 / Positive dilates; negative erodes the mask."}),
"mask_mode": (["inside", "outside"], {"default": "inside", "tooltip": "inside:蒙版内;outside:蒙版外 / Repaint inside or invert the mask."}),
"fill": (["original", "fill", "latent_noise", "latent_nothing"], {"default": "original", "tooltip": "原图像素、周围颜色填充、潜空间噪声、空白潜空间;后两项由一体采样节点在 latent 层实现 / Original, surrounding-color fill, latent noise, or zero latent. Latent modes require the all-in-one sampler."}),
"resize_mode": (["stretch", "crop", "fill"], {"default": "stretch", "tooltip": "整图模式:拉伸、居中裁剪、保比例填充;仅蒙版模式自动保留完整蒙版 / Whole-image resize mode; mask-only mode always retains the entire mask."}),
"image": ("IMAGE", {"tooltip": "Original image; output is restored to this exact size."}),
"mask": ("MASK", {"tooltip": "White repaints; black preserves."}),
"area": (["whole_image", "only_masked"], {"default": "only_masked", "tooltip": "Whole image or mask bounding-box inpainting."}),
"width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "Sampling canvas width, independent of final image size."}),
"height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "Sampling canvas height."}),
"padding": ("INT", {"default": 32, "min": 0, "max": 2048, "step": 1, "tooltip": "Mask-only context padding in original pixels."}),
"mask_blur": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 128.0, "step": 0.5, "tooltip": "Gaussian mask feathering in original pixels."}),
"mask_grow": ("INT", {"default": 0, "min": -128, "max": 128, "step": 1, "tooltip": "Positive dilates; negative erodes the mask."}),
"mask_mode": (["inside", "outside"], {"default": "inside", "tooltip": "Repaint inside or invert the mask."}),
"fill": (["original", "fill", "latent_noise", "latent_nothing"], {"default": "original", "tooltip": "Original, surrounding-color fill, latent noise, or zero latent. Latent modes require the all-in-one sampler."}),
"resize_mode": (["stretch", "crop", "fill"], {"default": "stretch", "tooltip": "Whole-image resize mode; mask-only mode always retains the entire mask."}),
}
@@ -307,34 +320,50 @@ class AdachiInlineMask:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"image": ("STRING", {"default": "", "tooltip": "ComfyUI input 图片路径,由内联编辑器上传 / Input image path populated by inline upload."}),
"mask_data": ("STRING", {"default": "", "multiline": False, "tooltip": "编辑器保存的白底重绘蒙版 PNG / Editor mask PNG data URL; white repaints."}),
"source_data": ("STRING", {"default": "", "multiline": False, "tooltip": "嵌入原图以便工作流在其他机器使用 / Embedded source image for portable workflows."}),
"image": ("STRING", {"default": "", "tooltip": "Input image path populated by inline upload."}),
"mask_data": ("STRING", {"default": "", "multiline": False, "tooltip": "Editor mask PNG data URL; white repaints."}),
"source_data": ("STRING", {"default": "", "multiline": False, "tooltip": "Embedded source image for portable workflows."}),
}, "optional": {
"edit_mode": (["mask", "crop"], {"default": "mask"}),
"crop_state": ("STRING", {"default": "{}"}),
"blend_data": ("STRING", {"default": ""}),
"layer_image": ("STRING", {"default": ""}),
"paint_data": ("STRING", {"default": "", "multiline": False, "tooltip": "Transparent color paint layer PNG."}),
}}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
RETURN_NAMES = ("image", "mask", "full_image")
FUNCTION = "load"
CATEGORY = CATEGORY
DESCRIPTION = "直接在工作流节点内涂抹蒙版,无需打开独立编辑器 / Paint an inpaint mask directly in the workflow."
DESCRIPTION = "Paint an inpaint mask directly in the workflow."
SEARCH_ALIASES = ["inline mask", "webui inpaint", "局部重绘", "安达", "adachi"]
@classmethod
def IS_CHANGED(cls, image="", mask_data="", source_data=""):
def IS_CHANGED(cls, image="", mask_data="", source_data="", **kwargs):
digest = hashlib.sha256()
for value in (image, mask_data, source_data):
for value in (image, mask_data, source_data, json.dumps(kwargs, sort_keys=True, default=str)):
digest.update(str(value).encode("utf-8"))
digest.update(b"\x00")
if not source_data:
paint_data = kwargs.get("paint_data", "")
if paint_data and not paint_data.startswith("data:"):
try:
with _input_path(image).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
stat = _input_path(paint_data).stat()
digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("utf-8"))
except (ValueError, OSError):
return float("nan")
if not source_data:
# The source is a stable input file; hashing its size and mtime is
# enough to detect a replacement and avoids reading a full high-res
# PNG on every change/execution. Content-hash the bytes only when
# the metadata cannot be read (the loader will then raise anyway).
try:
stat = _input_path(image).stat()
digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("utf-8"))
except (ValueError, OSError):
return float("nan")
return digest.hexdigest()
def load(self, image="", mask_data="", source_data=""):
def load(self, image="", mask_data="", source_data="", edit_mode="mask", crop_state="{}", blend_data="", layer_image="", paint_data="", result=None):
raster = _data_image(source_data) if source_data else _read_raster(_input_path(image))
rgba = raster.convert("RGBA")
pixels = torch.from_numpy(np.asarray(rgba.convert("RGB"), dtype=np.float32).copy() / 255.0)[None]
@@ -345,22 +374,42 @@ class AdachiInlineMask:
mask = np.asarray(mask_image, dtype=np.float32).copy() / 255.0
else:
mask = 1.0 - np.asarray(rgba.getchannel("A"), dtype=np.float32).copy() / 255.0
return pixels, torch.from_numpy(mask)[None]
mask = torch.from_numpy(mask)[None]
if edit_mode == "crop":
state = _crop_state(crop_state)
if state.get("locked"):
x, y, width, height = _crop_box(state, pixels.shape[2], pixels.shape[1])
composite = _paint_layer(_blend_layer(pixels, _load_layer(layer_image), blend_data, state), paint_data)
return composite[:, y:y+height, x:x+width].clone(), mask[:, y:y+height, x:x+width].clone(), composite
pixels = _paint_layer(pixels, paint_data)
return pixels, mask, pixels
def _paint_layer(pixels, paint_data):
"""Composite a full-image RGBA paint layer without changing the repaint mask."""
if not paint_data:
return pixels
layer = (_data_image(paint_data) if paint_data.startswith("data:") else _read_raster(_input_path(paint_data))).convert("RGBA")
if layer.size != (pixels.shape[2], pixels.shape[1]):
raise ValueError("The paint layer dimensions must match the source image.")
rgba = torch.from_numpy(np.asarray(layer, dtype=np.float32).copy() / 255.0)
alpha = rgba[..., 3:4].unsqueeze(0)
return pixels * (1.0 - alpha) + rgba[..., :3].unsqueeze(0) * alpha
class AdachiInpaintPrepare:
@classmethod
def INPUT_TYPES(cls):
return {"required": _prepare_inputs(), "optional": {
"soft_edge": ("BOOLEAN", {"default": False, "tooltip": "额外羽化;不是 A1111 Soft Inpainting 算法 / Extra feathering, not AUTOMATIC1111's Soft Inpainting algorithm."}),
"soft_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "tooltip": "额外增加 0–16 像素羽化 / Adds 0–16 pixels of Gaussian feathering."}),
"soft_edge": ("BOOLEAN", {"default": False, "tooltip": "Extra feathering, not AUTOMATIC1111's Soft Inpainting algorithm."}),
"soft_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "tooltip": "Adds 0–16 pixels of Gaussian feathering."}),
}}
RETURN_TYPES = ("IMAGE", "MASK", "ADACHI_INPAINT_CONTEXT")
RETURN_NAMES = ("pixels", "mask", "context")
FUNCTION = "prepare"
CATEGORY = CATEGORY
DESCRIPTION = "局部重绘裁剪、蒙版处理与精确回贴上下文;latent 填充请使用一体采样节点 / Crop and prepare masks with exact compositing context; use the all-in-one sampler for latent fill modes."
DESCRIPTION = "Crop and prepare masks with exact compositing context; use the all-in-one sampler for latent fill modes."
SEARCH_ALIASES = ["inpaint crop", "局部重绘预处理", "webui", "adachi"]
def prepare(self, image, mask, **kwargs):
@@ -371,15 +420,15 @@ class AdachiInpaintComposite:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"generated": ("IMAGE", {"tooltip": "采样后的裁剪图 / Generated sampling canvas."}),
"context": ("ADACHI_INPAINT_CONTEXT", {"tooltip": "预处理节点的裁剪和原图上下文 / Context from Adachi Inpaint Prepare."}),
"generated": ("IMAGE", {"tooltip": "Generated sampling canvas."}),
"context": ("ADACHI_INPAINT_CONTEXT", {"tooltip": "Context from Adachi Inpaint Prepare."}),
}}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "composite"
CATEGORY = CATEGORY
DESCRIPTION = "还原原图尺寸,软蒙版外保持原像素不变 / Restore original size and preserve pixels outside the effective mask exactly."
DESCRIPTION = "Restore original size and preserve pixels outside the effective mask exactly."
SEARCH_ALIASES = ["inpaint paste", "局部重绘回贴", "adachi"]
def composite(self, generated, context):
@@ -396,6 +445,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)
@@ -430,30 +531,30 @@ class AdachiWebUIInpaint:
required = {
"model": ("MODEL",), "positive": ("CONDITIONING",), "negative": ("CONDITIONING",), "vae": ("VAE",),
**_prepare_inputs(),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True, "tooltip": "可复现批量随机种子 / Seed for a reproducible batch of distinct samples."}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True, "tooltip": "Seed for a reproducible batch of distinct samples."}),
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
"cfg": ("FLOAT", {"default": 7.0, "min": 0.0, "max": 100.0, "step": 0.1}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
"denoise": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "0 原样返回原图,1 完全重绘 / Zero returns the original exactly; one fully denoises."}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 16, "tooltip": "每张输入图生成数量 / Number of generated variants per input image."}),
"refiner_switch": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "接入 Refiner 时的采样切换比例;0全程refiner,1全程base / Fraction of steps on the base model when a refiner is connected."}),
"denoise": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Zero returns the original exactly; one fully denoises."}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 16, "tooltip": "Number of generated variants per input image."}),
"refiner_switch": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Fraction of steps on the base model when a refiner is connected."}),
}
return {"required": required, "optional": {
"inpaint_model": ("BOOLEAN", {"default": False, "tooltip": "仅专用 inpaint 模型开启,添加原生 inpaint conditioning / Enable only for dedicated inpainting models; uses ComfyUI's native InpaintModelConditioning."}),
"soft_edge": ("BOOLEAN", {"default": False, "tooltip": "额外羽化,并非 A1111 Soft Inpainting 原算法 / Extra feathering, not A1111 Soft Inpainting."}),
"inpaint_model": ("BOOLEAN", {"default": False, "tooltip": "Enable only for dedicated inpainting models; uses ComfyUI's native InpaintModelConditioning."}),
"soft_edge": ("BOOLEAN", {"default": False, "tooltip": "Extra feathering, not A1111 Soft Inpainting."}),
"soft_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}),
"refiner_model": ("MODEL", {"tooltip": "可选的兼容 Refiner 模型(例如 SDXL refiner) / Optional compatible refiner model, e.g. SDXL refiner."}),
"refiner_positive": ("CONDITIONING", {"tooltip": "用 Refiner 的 CLIP 编码;未连接则沿用原始正面条件 / Encode using the refiner CLIP; defaults to original positive conditioning."}),
"refiner_negative": ("CONDITIONING", {"tooltip": "用 Refiner 的 CLIP 编码;未连接则沿用原始负面条件 / Encode using the refiner CLIP; defaults to original negative conditioning."}),
"refiner_model": ("MODEL", {"tooltip": "Optional compatible refiner model, e.g. SDXL refiner."}),
"refiner_positive": ("CONDITIONING", {"tooltip": "Encode using the refiner CLIP; defaults to original positive conditioning."}),
"refiner_negative": ("CONDITIONING", {"tooltip": "Encode using the refiner CLIP; defaults to original negative conditioning."}),
}}
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK")
RETURN_NAMES = ("image", "crop", "mask")
FUNCTION = "sample"
CATEGORY = CATEGORY
DESCRIPTION = "WebUI 式局部重绘:整图/蒙版区域、填充、采样和原尺寸回贴 / WebUI-style inpainting, crop, latent fill, sampling and original-size compositing."
SEARCH_ALIASES = ["webui", "img2img", "inpaint", "局部重绘", "安达", "adachi"]
DESCRIPTION = "WebUI-style inpainting, crop, latent fill, sampling and original-size compositing."
SEARCH_ALIASES = ["webui", "img2img", "inpaint", "局部重绘", "安达重绘工作台", "安达", "adachi"]
def sample(self, model, positive, negative, vae, image, mask, seed=0, steps=20, cfg=7.0,
sampler_name="euler", scheduler="normal", denoise=0.75, batch_size=1, inpaint_model=False,
@@ -478,13 +579,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 +626,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
@@ -525,32 +636,423 @@ class AdachiImageCompare:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"original": ("IMAGE", {"tooltip": "左侧原图,连接内联蒙版的 image / Original image shown on the left."}),
"result": ("IMAGE", {"tooltip": "右侧完整重绘图,请连接最终 image 而非 crop / Full-size generated image shown on the right; use image, not crop."}),
}}
"original": ("IMAGE", {"tooltip": "Original image shown on the right."}),
"result": ("IMAGE", {"tooltip": "Full-size generated image shown on the left; use image, not crop."}),
}, "hidden": {"prompt": "PROMPT", "unique_id": "UNIQUE_ID", "dynprompt": "DYNPROMPT"}}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "compare"
OUTPUT_NODE = True
CATEGORY = CATEGORY
DESCRIPTION = "鼠标竖线左右对比原图与重绘结果;点击采用此结果可送回上游内联蒙版 / Hover to compare; adopt a result into the upstream inline editor."
DESCRIPTION = "Hover to compare; adopt a result into the upstream inline editor."
SEARCH_ALIASES = ["compare", "before after", "图像对比", "adachi", "安达工作台"]
def compare(self, original, result):
import nodes
@classmethod
def IS_CHANGED(cls, original, result, **kwargs):
# All evictions invalidate old cached UI descriptors; the store also
# identifies its process so a restarted server cannot replay stale URLs.
return PREVIEWS.fingerprint()
def compare(self, original, result, prompt=None, unique_id=None, dynprompt=None):
original, result = _image_tensor(original), _image_tensor(result)
if original.shape[1:3] != result.shape[1:3]:
raise ValueError("原图与结果尺寸须一致,请连接最终 image 输出而非 crop / Image sizes must match. Connect the final image output, not crop.")
raise ValueError("Image sizes must match. Connect the final image output, not crop.")
if result.shape[0] % original.shape[0]:
raise ValueError("结果批量须为原图批量的整数倍 / Result batch must be a multiple of the original batch.")
raise ValueError("Result batch must be a multiple of the original batch.")
if original.shape[1] * original.shape[2] > MAX_IMAGE_PIXELS or max(original.shape[1:3]) > 16384:
raise ValueError("Comparison images exceed the inline editor's 32 megapixel / 16384px limit.")
preview = nodes.PreviewImage()
before = preview.save_images(original, filename_prefix="adachi_compare_original")["ui"]["images"]
after = preview.save_images(result, filename_prefix="adachi_compare_result")["ui"]["images"]
return {"ui": {"adachi_originals": before, "adachi_results": after}, "result": (result,)}
images, encoded_size = [], 0
for batch in (original, result):
for pixels in batch:
rgb = np.clip(255.0 * pixels.cpu().numpy(), 0, 255).astype(np.uint8)
stream = io.BytesIO()
Image.fromarray(rgb).save(stream, format="PNG", compress_level=1)
data = stream.getvalue()
encoded_size += len(data)
if encoded_size > PREVIEWS.limit:
raise ValueError("The comparison batch exceeds the in-memory preview limit. Reduce batch size or increase ADACHI_COMPARE_MEMORY_MIB (64–4096 MiB).")
images.append(data)
refs = PREVIEWS.put_batch(images, unique_id)
before, after = refs[:original.shape[0]], refs[original.shape[0]:]
origin = _comparison_origin(prompt, unique_id, dynprompt)
return {"ui": {"adachi_originals": before, "adachi_results": after, "adachi_origin": [origin] if origin else []}, "result": (result,)}
_MISSING_INPUT = object()
def _workflow_exclusive_flags(extra_pnginfo, unique_id):
"""Recover routing states from the editable graph, before virtual links vanish."""
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
if not isinstance(workflow, dict) or unique_id is None:
return None
definitions = {str(g.get("id")): g for g in workflow.get("definitions", {}).get("subgraphs", [])}
graph = workflow
parts = str(unique_id).split(":")
for parent_id in parts[:-1]:
parent = next((n for n in graph.get("nodes", []) if str(n.get("id")) == parent_id), None)
graph = definitions.get(str(parent.get("type"))) if parent else None
if graph is None:
return None
nodes = {str(n.get("id")): n for n in graph.get("nodes", [])}
selector = nodes.get(parts[-1])
if not selector or selector.get("type") != "AdachiExclusiveSwitch":
return None
links = {}
for link in graph.get("links", []):
if isinstance(link, dict):
links[str(link["id"])] = (str(link["origin_id"]), link.get("origin_slot", 0))
else:
links[str(link[0])] = (str(link[1]), link[2])
def visit(node_id, slot, seen):
if node_id in seen:
return False
node = nodes.get(node_id)
if node is None:
return None # e.g. a subgraph's external input placeholder
if node.get("mode", 0) in (2, 4):
return False
seen = seen | {node_id}
kind = node.get("type")
if kind == "GetNode":
values = node.get("widgets_values", [])
name = values[0] if isinstance(values, list) and values else None
setter = next((n for n in nodes.values() if n.get("type") == "SetNode"
and isinstance(n.get("widgets_values"), list)
and n["widgets_values"] and n["widgets_values"][0] == name), None) if name else None
return visit(str(setter["id"]), slot, seen) if setter else False
if kind in ("SetNode", "Reroute"):
inputs = node.get("inputs", [])
if not inputs:
return False
source = inputs[slot] if isinstance(slot, int) and 0 <= slot < len(inputs) else inputs[0]
link = links.get(str(source.get("link")))
return visit(*link, seen) if link else False
return True
flags = 0
for name, bit in (("input_a", 1), ("input_b", 2)):
source = next((i for i in selector.get("inputs", []) if i.get("name") == name), {})
link = links.get(str(source.get("link")))
enabled = visit(*link, set()) if link else False
if enabled is None:
return None
if enabled:
flags |= bit
return flags
def _workflow_exclusive_sources(extra_pnginfo, unique_id, prompt):
"""Resolve saved graph outputs to existing flattened execution nodes only."""
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
if not isinstance(workflow, dict):
return {}
definitions = {str(g.get("id")): g for g in workflow.get("definitions", {}).get("subgraphs", [])}
def index(graph):
nodes = {str(n.get("id")): n for n in graph.get("nodes", [])}
links = {}
for link in graph.get("links", []):
if isinstance(link, dict):
links[str(link["id"])] = (str(link["origin_id"]), link.get("origin_slot", 0))
else:
links[str(link[0])] = (str(link[1]), link[2])
return nodes, links
graph, path = workflow, []
parts = str(unique_id).split(":")
for parent_id in parts[:-1]:
nodes, _ = index(graph)
parent = nodes.get(parent_id)
graph = definitions.get(str(parent.get("type"))) if parent else None
if graph is None:
return {}
path.append(parent_id)
nodes, links = index(graph)
selector = nodes.get(parts[-1])
if not selector or selector.get("type") != "AdachiExclusiveSwitch":
return {}
def resolve(current, node_id, slot, prefix, seen):
key = (id(current), node_id, slot)
if key in seen or len(seen) > 64:
return None
seen = seen | {key}
current_nodes, current_links = index(current)
node = current_nodes.get(node_id)
if not node or node.get("mode", 0) in (2, 4):
return None
kind = node.get("type")
if kind == "GetNode":
values = node.get("widgets_values", [])
name = values[0] if isinstance(values, list) and values else None
setter = next((n for n in current_nodes.values() if n.get("type") == "SetNode"
and isinstance(n.get("widgets_values"), list)
and n["widgets_values"] and n["widgets_values"][0] == name), None) if name else None
return resolve(current, str(setter["id"]), slot, prefix, seen) if setter else None
if kind in ("SetNode", "Reroute"):
inputs = node.get("inputs", [])
source = inputs[slot] if isinstance(slot, int) and 0 <= slot < len(inputs) else inputs[0] if inputs else {}
link = current_links.get(str(source.get("link")))
return resolve(current, *link, prefix, seen) if link else None
if kind in definitions:
child = definitions[kind]
outputs = child.get("outputs", [])
if not isinstance(slot, int) or not 0 <= slot < len(outputs):
return None
_, child_links = index(child)
output_links = [child_links[str(i)] for i in outputs[slot].get("linkIds", []) if str(i) in child_links]
if len(output_links) != 1:
return None
return resolve(child, *output_links[0], prefix + [node_id], seen)
execution_id = ":".join(prefix + [node_id])
execution_node = prompt.get(execution_id)
if isinstance(execution_node, dict) and execution_node.get("class_type") == kind:
return [execution_id, slot]
return None
result = {}
for source in selector.get("inputs", []):
name = source.get("name")
link = links.get(str(source.get("link")))
if name in ("input_a", "input_b") and link:
resolved = resolve(graph, *link, path, set())
if resolved is not None:
result[name] = resolved
return result
def normalize_exclusive_prompt(data):
"""Resolve flags before validation/cache keys, without changing workflow wires."""
prompt = data.get("prompt", {})
extra = data.get("extra_data", {}).get("extra_pnginfo")
if not isinstance(prompt, dict):
return data
for node_id, node in prompt.items():
if not isinstance(node, dict) or node.get("class_type") != "AdachiExclusiveSwitch":
continue
flags = _workflow_exclusive_flags(extra, node_id)
if flags is None:
continue
inputs = node.setdefault("inputs", {})
inputs["active_inputs"] = flags
sources = _workflow_exclusive_sources(extra, node_id, prompt) if flags in (1, 2) else {}
for name, bit in (("input_a", 1), ("input_b", 2)):
if not flags & bit:
inputs.pop(name, None)
elif name not in inputs and name in sources:
inputs[name] = sources[name]
return normalize_crop_prompt(data)
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}),
},
"hidden": {"prompt": "PROMPT", "dynprompt": "DYNPROMPT", "unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO"},
}
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, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
frontend_flags, submitted_flags = active_inputs, None
workflow_flags = _workflow_exclusive_flags(extra_pnginfo, unique_id)
if workflow_flags is not None:
active_inputs = workflow_flags
execution_node = None
if dynprompt is not None and unique_id is not None:
try:
execution_node = dynprompt.get_node(unique_id)
except KeyError:
pass
if execution_node is None and isinstance(prompt, dict) and unique_id is not None:
execution_node = prompt.get(str(unique_id), prompt.get(unique_id))
if isinstance(execution_node, dict) and isinstance(execution_node.get("inputs"), dict):
# Get/Set and mute/bypass resolution may remove a connection after
# widgets serialize. Trust the actual submitted connections when
# only one (or none) survives, regardless of stale frontend flags.
connected = execution_node["inputs"]
actual = int("input_a" in connected) | (int("input_b" in connected) << 1)
submitted_flags = actual
if actual != 3:
active_inputs = actual if workflow_flags is None else actual & workflow_flags
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. "
f"Received enabled-state={active_inputs}. "
f"node={unique_id}, frontend={frontend_flags}, workflow={workflow_flags}, submitted={submitted_flags}."
)
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, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
return [self._selected_input(input_a, input_b, active_inputs, prompt, dynprompt, unique_id, extra_pnginfo)]
def select(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
selected = self._selected_input(input_a, input_b, active_inputs, prompt, dynprompt, unique_id, extra_pnginfo)
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,)
def _crop_state(value):
try:
state = json.loads(value or "{}")
except (ValueError, TypeError) as exc:
raise ValueError("Invalid crop selection data.") from exc
if not isinstance(state, dict):
raise ValueError("Invalid crop selection data.")
return state
def _crop_box(state, image_width, image_height):
values = [state.get(k) for k in ("x", "y", "width", "height")]
if any(type(v) is not int for v in values):
raise ValueError("Crop coordinates and dimensions must be integers.")
x, y, width, height = values
if min(width, height) < 32 or width % 32 or height % 32:
raise ValueError("Crop width and height must be positive multiples of 32.")
if x < 0 or y < 0 or x + width > image_width or y + height > image_height:
raise ValueError("The crop must fit entirely inside the source image.")
return x, y, width, height
def _load_layer(filename):
if not filename:
return None
raster = _read_raster(_input_path(filename)).convert("RGB")
return torch.from_numpy(np.asarray(raster, dtype=np.float32).copy() / 255)[None]
def _blend_layer(original, generated, blend_data, state):
if generated is None:
return original
x, y, width, height = _crop_box(state, original.shape[2], original.shape[1])
generated = _image_tensor(generated).to(device=original.device, dtype=original.dtype)
if generated.shape[1:3] != (height, width):
raise ValueError(f"Inpaint result must match the selected crop ({width} x {height}); connect the inpaint node's image output.")
alpha = torch.ones((1, height, width, 1), device=original.device, dtype=original.dtype)
if blend_data:
raster = _data_image(blend_data).convert("L")
if raster.size != (width, height):
raise ValueError("The layer mask dimensions must match the crop.")
alpha = torch.from_numpy(np.asarray(raster, dtype=np.float32).copy() / 255)[None, ..., None].to(original)
composite = original.expand(generated.shape[0], -1, -1, -1).clone()
old = composite[:, y:y+height, x:x+width]
composite[:, y:y+height, x:x+width] = old * (1-alpha) + generated * alpha
return composite
def _store_layer(image):
import folder_paths
pixels = (image.detach().cpu().clamp(0, 1).numpy()*255).round().astype(np.uint8)
name = f"layer-{hashlib.sha256(str(pixels.shape).encode() + pixels.tobytes()).hexdigest()}.png"
folder = Path(folder_paths.get_input_directory()) / "adachi-inpaint-layers"
folder.mkdir(parents=True, exist_ok=True)
if not (folder / name).exists():
# Readers never see an incomplete PNG when concurrent prompts reuse a layer.
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as stream:
temporary = Path(stream.name)
try:
Image.fromarray(pixels).save(temporary)
temporary.replace(folder / name)
finally:
temporary.unlink(missing_ok=True)
return {"filename": name, "subfolder": "adachi-inpaint-layers", "type": "input"}
class AdachiLayerCommit:
"""Execution-only sink; keeps the visible feedback loop acyclic at runtime."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"result": ("IMAGE",), "image": ("STRING", {"default": ""}),
"source_data": ("STRING", {"default": ""}), "crop_state": ("STRING", {"default": "{}"}),
"blend_data": ("STRING", {"default": ""}), "target": ("STRING", {"default": ""}),
"paint_data": ("STRING", {"default": ""}),
}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "compose"
OUTPUT_NODE = True
CATEGORY = "_Adachi/internal"
DESCRIPTION = "Internal layer sink. Use Inline Mask in Crop & Blend mode."
def compose(self, result, image="", source_data="", crop_state="{}", blend_data="", target="", paint_data=""):
raster = (_data_image(source_data) if source_data else _read_raster(_input_path(image))).convert("RGB")
original = torch.from_numpy(np.asarray(raster, dtype=np.float32).copy()/255)[None]
state = _crop_state(crop_state)
generated = _image_tensor(result).cpu()
composite = _paint_layer(_blend_layer(original, generated, blend_data, state), paint_data)
layer = _store_layer(generated[-1])
return {"ui": {"adachi_layer": [{"target": target, "job": state.get("job", ""), "image": layer}]}, "result": (composite,)}
def normalize_crop_prompt(data):
"""Ignore obsolete feedback links; adoption now happens only in Image Compare."""
prompt = data.get("prompt", {})
if isinstance(prompt, dict):
for node in prompt.values():
if isinstance(node, dict) and node.get("class_type") == "AdachiInlineMask":
node.setdefault("inputs", {}).pop("result", None)
return data
def _comparison_origin(prompt, unique_id, dynprompt=None):
"""Tag previews with their generating crop, so later adoption cannot hit another region."""
def get_node(node_id):
if dynprompt is not None:
try:
return dynprompt.get_node(str(node_id))
except KeyError:
pass
return prompt.get(str(node_id), prompt.get(node_id)) if isinstance(prompt, dict) else None
node = get_node(unique_id)
link = node.get("inputs", {}).get("original") if isinstance(node, dict) else None
queue = [str(link[0])] if isinstance(link, list) and len(link) == 2 else []
seen, sources = set(), {}
while queue and len(seen) < 256:
node_id = queue.pop()
if node_id in seen:
continue
seen.add(node_id)
current = get_node(node_id)
if not isinstance(current, dict):
continue
inputs = current.get("inputs", {})
if current.get("class_type") == "AdachiInlineMask":
mode = inputs.get("edit_mode", "mask")
state = _crop_state(inputs.get("crop_state", "{}")) if mode == "crop" else {}
sources[node_id] = {"source": node_id, "mode": mode, "job": state.get("job", "")}
continue
for value in inputs.values():
if isinstance(value, list) and len(value) == 2 and type(value[1]) is int:
queue.append(str(value[0]))
return next(iter(sources.values())) if len(sources) == 1 else None
NODE_CLASS_MAPPINGS = {
@@ -559,12 +1061,16 @@ NODE_CLASS_MAPPINGS = {
"AdachiInpaintComposite": AdachiInpaintComposite,
"AdachiWebUIInpaint": AdachiWebUIInpaint,
"AdachiImageCompare": AdachiImageCompare,
"AdachiExclusiveSwitch": AdachiExclusiveSwitch,
"AdachiLayerCommit": AdachiLayerCommit,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AdachiInlineMask": "内联蒙版 / Inline Mask",
"AdachiInpaintPrepare": "重绘预处理 / Inpaint Prepare",
"AdachiInpaintComposite": "原图回贴 / Inpaint Composite",
"AdachiWebUIInpaint": "WebUI 局部重绘 / WebUI Inpaint",
"AdachiImageCompare": "图像对比 / Image Compare",
"AdachiInlineMask": "Inline Mask",
"AdachiInpaintPrepare": "Inpaint Prepare",
"AdachiInpaintComposite": "Inpaint Composite",
"AdachiWebUIInpaint": "Adachi Inpaint Workbench",
"AdachiImageCompare": "Image Compare",
"AdachiExclusiveSwitch": "Auto Exclusive Switch",
"AdachiLayerCommit": "Internal Layer Commit",
}
-746
View File
@@ -1,746 +0,0 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 8,
"last_link_id": 11,
"nodes": [
{
"id": 1,
"type": "AdachiInlineMask",
"pos": [
40,
80
],
"size": [
660,
700
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "STRING",
"widget": {
"name": "image"
},
"link": null
},
{
"localized_name": "mask_data",
"name": "mask_data",
"type": "STRING",
"widget": {
"name": "mask_data"
},
"link": null
},
{
"localized_name": "source_data",
"name": "source_data",
"type": "STRING",
"widget": {
"name": "source_data"
},
"link": null
}
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
7,
9
]
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "AdachiInlineMask"
},
"widgets_values": [
"",
"",
""
]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [
780,
80
],
"size": [
340,
110
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "Checkpoint名称",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "模型",
"name": "MODEL",
"type": "MODEL",
"links": [
3
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
1,
2
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"CHOOSE_YOUR_CHECKPOINT.safetensors"
]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [
780,
250
],
"size": [
350,
150
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 1
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
5
]
}
],
"title": "正面提示词 / Positive",
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"A detailed natural photograph, coherent lighting, high quality"
]
},
{
"id": 4,
"type": "CLIPTextEncode",
"pos": [
780,
450
],
"size": [
350,
150
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 2
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
6
]
}
],
"title": "负面提示词 / Negative",
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"blurry, distorted, artifacts"
]
},
{
"id": 5,
"type": "AdachiWebUIInpaint",
"pos": [
1220,
80
],
"size": [
430,
780
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 3
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 5
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 4
},
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 7
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": 8
},
{
"localized_name": "refiner_model",
"name": "refiner_model",
"shape": 7,
"type": "MODEL",
"link": null
},
{
"localized_name": "refiner_positive",
"name": "refiner_positive",
"shape": 7,
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "refiner_negative",
"name": "refiner_negative",
"shape": 7,
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "area",
"name": "area",
"type": "COMBO",
"widget": {
"name": "area"
},
"link": null
},
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"localized_name": "padding",
"name": "padding",
"type": "INT",
"widget": {
"name": "padding"
},
"link": null
},
{
"localized_name": "mask_blur",
"name": "mask_blur",
"type": "FLOAT",
"widget": {
"name": "mask_blur"
},
"link": null
},
{
"localized_name": "mask_grow",
"name": "mask_grow",
"type": "INT",
"widget": {
"name": "mask_grow"
},
"link": null
},
{
"localized_name": "mask_mode",
"name": "mask_mode",
"type": "COMBO",
"widget": {
"name": "mask_mode"
},
"link": null
},
{
"localized_name": "fill",
"name": "fill",
"type": "COMBO",
"widget": {
"name": "fill"
},
"link": null
},
{
"localized_name": "resize_mode",
"name": "resize_mode",
"type": "COMBO",
"widget": {
"name": "resize_mode"
},
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "sampler_name",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "scheduler",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "denoise",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
},
{
"localized_name": "refiner_switch",
"name": "refiner_switch",
"type": "FLOAT",
"widget": {
"name": "refiner_switch"
},
"link": null
},
{
"localized_name": "inpaint_model",
"name": "inpaint_model",
"shape": 7,
"type": "BOOLEAN",
"widget": {
"name": "inpaint_model"
},
"link": null
},
{
"localized_name": "soft_edge",
"name": "soft_edge",
"shape": 7,
"type": "BOOLEAN",
"widget": {
"name": "soft_edge"
},
"link": null
},
{
"localized_name": "soft_strength",
"name": "soft_strength",
"shape": 7,
"type": "FLOAT",
"widget": {
"name": "soft_strength"
},
"link": null
}
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
10
]
},
{
"localized_name": "crop",
"name": "crop",
"type": "IMAGE",
"links": null
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "AdachiWebUIInpaint"
},
"widgets_values": [
"only_masked",
1024,
1024,
32,
4,
0,
"inside",
"original",
"stretch",
42,
"fixed",
20,
7,
"dpmpp_2m",
"karras",
0.75,
1,
0.8,
false,
false,
0.5
]
},
{
"id": 6,
"type": "AdachiImageCompare",
"pos": [
1770,
80
],
"size": [
590,
620
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "original",
"name": "original",
"type": "IMAGE",
"link": 9
},
{
"localized_name": "result",
"name": "result",
"type": "IMAGE",
"link": 10
}
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
11
]
}
],
"properties": {
"Node name for S&R": "AdachiImageCompare"
},
"widgets_values": []
},
{
"id": 7,
"type": "SaveImage",
"pos": [
2470,
80
],
"size": [
430,
450
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "图片",
"name": "images",
"type": "IMAGE",
"link": 11
},
{
"localized_name": "文件名前缀",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"Adachi/Inpaint"
]
},
{
"id": 8,
"type": "Note",
"pos": [
780,
670
],
"size": [
350,
220
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"安达工作台 / Adachi Workbench\n1. 选择自己的 checkpoint 喵\n2. 在左侧节点载入原图并直接涂抹喵\n3. only_masked = 蒙版区域放大采样,whole_image = 整图采样喵\n4. width/height 是采样分辨率,最终 image 回贴为原图尺寸喵\n分享时勾选「原图嵌入工作流」喵"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
2,
1,
3,
0,
"CLIP"
],
[
2,
2,
1,
4,
0,
"CLIP"
],
[
3,
2,
0,
5,
0,
"MODEL"
],
[
4,
2,
2,
5,
3,
"VAE"
],
[
5,
3,
0,
5,
1,
"CONDITIONING"
],
[
6,
4,
0,
5,
2,
"CONDITIONING"
],
[
7,
1,
0,
5,
4,
"IMAGE"
],
[
8,
1,
1,
5,
5,
"MASK"
],
[
9,
1,
0,
6,
0,
"IMAGE"
],
[
10,
5,
0,
6,
1,
"IMAGE"
],
[
11,
6,
0,
7,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}
File diff suppressed because it is too large Load Diff
+30 -9
View File
@@ -16,24 +16,36 @@ if (-not (Test-Path -LiteralPath $customRoot)) {
New-Item -ItemType Directory -Path $customRoot | Out-Null
}
$customRoot = (Resolve-Path -LiteralPath $customRoot).ProviderPath
$destination = [IO.Path]::GetFullPath((Join-Path $customRoot 'ComfyUI-Adachi-Workbench'))
$destination = [IO.Path]::GetFullPath((Join-Path $customRoot 'Adachi-Inpaint-Workbench'))
$legacyPath = [IO.Path]::GetFullPath((Join-Path $customRoot 'ComfyUI-Adachi-Workbench'))
if (-not $destination.StartsWith($customRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw 'Install destination must stay within the selected custom_nodes directory.'
}
if ($destination.TrimEnd('\') -eq $packageRoot.TrimEnd('\')) {
Write-Host 'This package is already in the selected ComfyUI custom_nodes directory. 喵'
Write-Host 'This package is already in the selected ComfyUI custom_nodes directory.'
exit 0
}
if (Test-Path -LiteralPath $destination) {
$existing = Get-Item -LiteralPath $destination
if ((Test-Path -LiteralPath $destination) -and (Test-Path -LiteralPath $legacyPath)) {
throw 'Both current and legacy plugin folders exist. Keep one installation to avoid duplicate node registrations.'
}
$existingPath = if (Test-Path -LiteralPath $destination) { $destination } elseif (Test-Path -LiteralPath $legacyPath) { $legacyPath } else { $null }
if ($existingPath) {
$existing = Get-Item -LiteralPath $existingPath
if ($existing.Attributes -band [IO.FileAttributes]::ReparsePoint) {
throw 'Refusing to overwrite a junction or symbolic-link installation. Use a normal folder.'
}
$backupRoot = Join-Path $comfyRoot 'user\adachi-workbench-backups'
New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null
$backupFile = Join-Path $backupRoot ('ComfyUI-Adachi-Workbench-' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff') + '.zip')
Compress-Archive -LiteralPath $destination -DestinationPath $backupFile
Write-Host "Previous installation backed up: $backupFile 喵"
$backupFile = Join-Path $backupRoot ('Adachi-Inpaint-Workbench-' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff') + '.zip')
Compress-Archive -LiteralPath $existingPath -DestinationPath $backupFile
Write-Host "Previous installation backed up: $backupFile"
if ($existingPath -ne $destination) {
# Verify both resolved absolute paths before moving the legacy folder.
if ([IO.Path]::GetDirectoryName($existingPath) -ne $customRoot -or [IO.Path]::GetDirectoryName($destination) -ne $customRoot) {
throw 'Migration paths must remain directly inside the selected custom_nodes directory.'
}
Move-Item -LiteralPath $existingPath -Destination $destination
}
} else {
New-Item -ItemType Directory -Path $destination | Out-Null
}
@@ -42,5 +54,14 @@ Get-ChildItem -LiteralPath $packageRoot -Force | Where-Object {
} | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $destination -Recurse -Force
}
Write-Host "Installed Adachi Workbench: $destination 喵"
Write-Host 'Restart ComfyUI, then refresh the browser with Ctrl+F5. 喵'
# These tracked files were retired in 0.6.2. The previous installation ZIP above
# retains them for rollback; remove them from an overwritten installation too.
foreach ($retiredRelative in @('web\magic_wand.js', 'web\magic_wand_worker.js', 'web\wand_selection.js', 'tests\test_magic_wand.mjs')) {
$retiredPath = [IO.Path]::GetFullPath((Join-Path $destination $retiredRelative))
if (-not $retiredPath.StartsWith($destination.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw 'Retired plugin file must stay within the install directory.'
}
if (Test-Path -LiteralPath $retiredPath -PathType Leaf) { Remove-Item -LiteralPath $retiredPath -Force }
}
Write-Host "Installed Adachi-Inpaint-Workbench: $destination"
Write-Host 'Restart ComfyUI, then refresh the browser with Ctrl+F5.'
+5
View File
@@ -0,0 +1,5 @@
{
"nodeCategories": {
"Adachi-Inpaint-Workbench": "Adachi-Inpaint-Workbench"
}
}
+270
View File
@@ -0,0 +1,270 @@
{
"AdachiInlineMask": {
"display_name": "Inline Mask",
"description": "Paint an inpaint mask directly in the workflow.",
"inputs": {
"image": {
"name": "image",
"tooltip": "Input image path populated by inline upload."
},
"mask_data": {
"name": "mask_data",
"tooltip": "Editor mask PNG data URL; white repaints."
},
"source_data": {
"name": "source_data",
"tooltip": "Embedded source image for portable workflows."
},
"edit_mode": {
"name": "edit_mode"
},
"crop_state": {
"name": "crop_state"
},
"blend_data": {
"name": "blend_data"
},
"layer_image": {
"name": "layer_image"
}
},
"outputs": {
"0": {
"name": "image"
},
"1": {
"name": "mask"
},
"2": {
"name": "full_image"
}
}
},
"AdachiInpaintPrepare": {
"display_name": "Inpaint Prepare",
"description": "Crop and prepare masks with exact compositing context; use the all-in-one sampler for latent fill modes.",
"inputs": {
"image": {
"name": "image",
"tooltip": "Original image; output is restored to this exact size."
},
"mask": {
"name": "mask",
"tooltip": "White repaints; black preserves."
},
"area": {
"name": "area",
"tooltip": "Whole image or mask bounding-box inpainting."
},
"width": {
"name": "width",
"tooltip": "Sampling canvas width, independent of final image size."
},
"height": {
"name": "height",
"tooltip": "Sampling canvas height."
},
"padding": {
"name": "padding",
"tooltip": "Mask-only context padding in original pixels."
},
"mask_blur": {
"name": "mask_blur",
"tooltip": "Gaussian mask feathering in original pixels."
},
"mask_grow": {
"name": "mask_grow",
"tooltip": "Positive dilates; negative erodes the mask."
},
"mask_mode": {
"name": "mask_mode",
"tooltip": "Repaint inside or invert the mask."
},
"fill": {
"name": "fill",
"tooltip": "Original, surrounding-color fill, latent noise, or zero latent. Latent modes require the all-in-one sampler."
},
"resize_mode": {
"name": "resize_mode",
"tooltip": "Whole-image resize mode; mask-only mode always retains the entire mask."
},
"soft_edge": {
"name": "soft_edge",
"tooltip": "Extra feathering, not AUTOMATIC1111's Soft Inpainting algorithm."
},
"soft_strength": {
"name": "soft_strength",
"tooltip": "Adds 0-16 pixels of Gaussian feathering."
}
},
"outputs": {
"0": {
"name": "pixels"
},
"1": {
"name": "mask"
},
"2": {
"name": "context"
}
}
},
"AdachiInpaintComposite": {
"display_name": "Inpaint Composite",
"description": "Restore original size and preserve pixels outside the effective mask exactly.",
"inputs": {
"generated": {
"name": "generated",
"tooltip": "Generated sampling canvas."
},
"context": {
"name": "context",
"tooltip": "Context from Adachi Inpaint Prepare."
}
},
"outputs": {
"0": {
"name": "image"
}
}
},
"AdachiWebUIInpaint": {
"display_name": "Adachi Inpaint Workbench",
"description": "WebUI-style inpainting, crop, latent fill, sampling and original-size compositing.",
"inputs": {
"image": {
"name": "image",
"tooltip": "Original image; output is restored to this exact size."
},
"mask": {
"name": "mask",
"tooltip": "White repaints; black preserves."
},
"area": {
"name": "area",
"tooltip": "Whole image or mask bounding-box inpainting."
},
"width": {
"name": "width",
"tooltip": "Sampling canvas width, independent of final image size."
},
"height": {
"name": "height",
"tooltip": "Sampling canvas height."
},
"padding": {
"name": "padding",
"tooltip": "Mask-only context padding in original pixels."
},
"mask_blur": {
"name": "mask_blur",
"tooltip": "Gaussian mask feathering in original pixels."
},
"mask_grow": {
"name": "mask_grow",
"tooltip": "Positive dilates; negative erodes the mask."
},
"mask_mode": {
"name": "mask_mode",
"tooltip": "Repaint inside or invert the mask."
},
"fill": {
"name": "fill",
"tooltip": "Original, surrounding-color fill, latent noise, or zero latent. Latent modes require the all-in-one sampler."
},
"resize_mode": {
"name": "resize_mode",
"tooltip": "Whole-image resize mode; mask-only mode always retains the entire mask."
},
"seed": {
"name": "seed",
"tooltip": "Seed for a reproducible batch of distinct samples."
},
"denoise": {
"name": "denoise",
"tooltip": "Zero returns the original exactly; one fully denoises."
},
"batch_size": {
"name": "batch_size",
"tooltip": "Number of generated variants per input image."
},
"refiner_switch": {
"name": "refiner_switch",
"tooltip": "Fraction of steps on the base model when a refiner is connected."
},
"inpaint_model": {
"name": "inpaint_model",
"tooltip": "Enable only for dedicated inpainting models; uses ComfyUI's native InpaintModelConditioning."
},
"soft_edge": {
"name": "soft_edge",
"tooltip": "Extra feathering, not A1111 Soft Inpainting."
},
"soft_strength": {
"name": "soft_strength",
"tooltip": "Adds 0–16 pixels of Gaussian feathering."
},
"refiner_model": {
"name": "refiner_model",
"tooltip": "Optional compatible refiner model, e.g. SDXL refiner."
},
"refiner_positive": {
"name": "refiner_positive",
"tooltip": "Encode using the refiner CLIP; defaults to original positive conditioning."
},
"refiner_negative": {
"name": "refiner_negative",
"tooltip": "Encode using the refiner CLIP; defaults to original negative conditioning."
}
},
"outputs": {
"0": {
"name": "image"
},
"1": {
"name": "crop"
},
"2": {
"name": "mask"
}
}
},
"AdachiImageCompare": {
"display_name": "Image Compare",
"description": "Hover to compare; adopt a result into the upstream inline editor.",
"inputs": {
"original": {
"name": "original",
"tooltip": "Original image shown on the right."
},
"result": {
"name": "result",
"tooltip": "Full-size generated image shown on the left; use image, not crop."
}
},
"outputs": {
"0": {
"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"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"nodeCategories": {
"Adachi-Inpaint-Workbench": "安达工作台 / Adachi-Inpaint-Workbench"
}
}
+270
View File
@@ -0,0 +1,270 @@
{
"AdachiInlineMask": {
"display_name": "内联蒙版 / Inline Mask",
"description": "直接在工作流节点内涂抹蒙版,无需打开独立编辑器。",
"inputs": {
"image": {
"name": "原图",
"tooltip": "由内联编辑器上传填充的 ComfyUI input 图片路径。"
},
"mask_data": {
"name": "蒙版数据",
"tooltip": "编辑器保存的蒙版 PNG 数据;白色重绘。"
},
"source_data": {
"name": "嵌入原图",
"tooltip": "嵌入原图,便于工作流在其他机器使用。"
},
"edit_mode": {
"name": "编辑模式"
},
"crop_state": {
"name": "裁切选区"
},
"blend_data": {
"name": "融合蒙版"
},
"layer_image": {
"name": "重绘图层"
}
},
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "蒙版"
},
"2": {
"name": "完整图像"
}
}
},
"AdachiInpaintPrepare": {
"display_name": "重绘预处理 / Inpaint Prepare",
"description": "局部重绘裁剪、蒙版处理与精确回贴上下文;latent 填充请使用一体采样节点。",
"inputs": {
"image": {
"name": "原图",
"tooltip": "原图;输出会还原为这个尺寸。"
},
"mask": {
"name": "蒙版",
"tooltip": "白色重绘,黑色保护。"
},
"area": {
"name": "重绘范围",
"tooltip": "整张原图重绘,或仅蒙版包围框区域重绘。"
},
"width": {
"name": "宽度",
"tooltip": "采样画布宽度;最终输出仍为原图尺寸。"
},
"height": {
"name": "高度",
"tooltip": "采样画布高度。"
},
"padding": {
"name": "外扩边距",
"tooltip": "仅蒙版区域:原图像素单位的上下文边距。"
},
"mask_blur": {
"name": "蒙版模糊",
"tooltip": "原图像素单位的高斯羽化半径。"
},
"mask_grow": {
"name": "蒙版扩张",
"tooltip": "正数扩张,负数收缩。"
},
"mask_mode": {
"name": "蒙版内外",
"tooltip": "重绘蒙版内,或反相重绘蒙版外。"
},
"fill": {
"name": "填充方式",
"tooltip": "原图像素、周围颜色填充、潜空间噪声或空白潜空间;后两项由一体采样节点在 latent 层实现。"
},
"resize_mode": {
"name": "缩放方式",
"tooltip": "整图模式的缩放方式;仅蒙版模式始终保留完整蒙版。"
},
"soft_edge": {
"name": "额外羽化",
"tooltip": "额外羽化;不是 A1111 的 Soft Inpainting 原算法。"
},
"soft_strength": {
"name": "羽化强度",
"tooltip": "额外增加 0–16 像素的高斯羽化。"
}
},
"outputs": {
"0": {
"name": "像素"
},
"1": {
"name": "蒙版"
},
"2": {
"name": "上下文"
}
}
},
"AdachiInpaintComposite": {
"display_name": "原图回贴 / Inpaint Composite",
"description": "还原原图尺寸,有效蒙版外保持原像素不变。",
"inputs": {
"generated": {
"name": "采样图",
"tooltip": "采样后的裁剪图。"
},
"context": {
"name": "上下文",
"tooltip": "预处理节点输出的裁剪与原图上下文。"
}
},
"outputs": {
"0": {
"name": "图像"
}
}
},
"AdachiWebUIInpaint": {
"display_name": "安达重绘工作台",
"description": "WebUI 式局部重绘:整图/蒙版区域、填充、采样与原尺寸回贴。",
"inputs": {
"image": {
"name": "原图",
"tooltip": "原图;输出会还原为这个尺寸。"
},
"mask": {
"name": "蒙版",
"tooltip": "白色重绘,黑色保护。"
},
"area": {
"name": "重绘范围",
"tooltip": "整张原图重绘,或仅蒙版包围框区域重绘。"
},
"width": {
"name": "宽度",
"tooltip": "采样画布宽度;最终输出仍为原图尺寸。"
},
"height": {
"name": "高度",
"tooltip": "采样画布高度。"
},
"padding": {
"name": "外扩边距",
"tooltip": "仅蒙版区域:原图像素单位的上下文边距。"
},
"mask_blur": {
"name": "蒙版模糊",
"tooltip": "原图像素单位的高斯羽化半径。"
},
"mask_grow": {
"name": "蒙版扩张",
"tooltip": "正数扩张,负数收缩。"
},
"mask_mode": {
"name": "蒙版内外",
"tooltip": "重绘蒙版内,或反相重绘蒙版外。"
},
"fill": {
"name": "填充方式",
"tooltip": "原图像素、周围颜色填充、潜空间噪声或空白潜空间。"
},
"resize_mode": {
"name": "缩放方式",
"tooltip": "整图模式的缩放方式;仅蒙版模式始终保留完整蒙版。"
},
"seed": {
"name": "随机种子",
"tooltip": "可复现的批量随机种子。"
},
"denoise": {
"name": "重绘幅度",
"tooltip": "0 原样返回原图,1 完全重绘。"
},
"batch_size": {
"name": "每张生成数量",
"tooltip": "每张输入图生成的变体数量。"
},
"refiner_switch": {
"name": "Refiner 切换",
"tooltip": "接入 Refiner 时 base 模型所占的采样步骤比例。"
},
"inpaint_model": {
"name": "专用重绘模型",
"tooltip": "仅专用 inpaint 模型开启;使用 ComfyUI 原生 InpaintModelConditioning。"
},
"soft_edge": {
"name": "额外羽化",
"tooltip": "额外羽化,并非 A1111 Soft Inpainting 原算法。"
},
"soft_strength": {
"name": "羽化强度",
"tooltip": "额外增加的高斯羽化强度。"
},
"refiner_model": {
"name": "Refiner 模型",
"tooltip": "可选的兼容 Refiner 模型,例如 SDXL refiner。"
},
"refiner_positive": {
"name": "Refiner 正面",
"tooltip": "用 Refiner 的 CLIP 编码;未连接则沿用原始正面条件。"
},
"refiner_negative": {
"name": "Refiner 负面",
"tooltip": "用 Refiner 的 CLIP 编码;未连接则沿用原始负面条件。"
}
},
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "裁剪图"
},
"2": {
"name": "蒙版"
}
}
},
"AdachiImageCompare": {
"display_name": "图像对比 / Image Compare",
"description": "鼠标悬停左右对比原图与重绘结果;点击可将结果回填到上游内联蒙版。",
"inputs": {
"original": {
"name": "原图",
"tooltip": "显示在右侧的原图,连接内联蒙版的 image。"
},
"result": {
"name": "重绘结果",
"tooltip": "显示在左侧的完整重绘图;请连接最终 image 而非 crop。"
}
},
"outputs": {
"0": {
"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: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+55
View File
@@ -0,0 +1,55 @@
"""Expire only this extension's comparison previews in ComfyUI's temp folder."""
import logging
import os
import re
import threading
import time
PREVIEW_MAX_AGE_SECONDS = 24 * 60 * 60
SWEEP_INTERVAL_SECONDS = 60 * 60
_PREVIEW_NAME = re.compile(r"adachi_compare_(?:original|result)_temp_[a-z]{5}_[0-9]{5,}_\.png\Z")
_started = False
_start_lock = threading.Lock()
def cleanup_comparison_previews(directory, *, now=None, max_age=PREVIEW_MAX_AGE_SECONDS):
"""Remove expired, regular preview files, leaving all other files untouched."""
cutoff = (time.time() if now is None else now) - max_age
removed = 0
try:
with os.scandir(directory) as entries:
for entry in entries:
if not _PREVIEW_NAME.fullmatch(entry.name):
continue
try:
if not entry.is_file(follow_symlinks=False):
continue
if entry.stat(follow_symlinks=False).st_mtime >= cutoff:
continue
os.unlink(entry.path)
removed += 1
except OSError as exc:
logging.debug("Could not remove expired comparison preview %s: %s", entry.name, exc)
except FileNotFoundError:
pass
except OSError as exc:
logging.warning("Could not inspect comparison preview directory: %s", exc)
return removed
def start_preview_cleanup():
"""Sweep in the background so a long-running ComfyUI does not accumulate previews."""
global _started
with _start_lock:
if _started:
return
_started = True
def run():
import folder_paths
while True:
cleanup_comparison_previews(folder_paths.get_temp_directory())
time.sleep(SWEEP_INTERVAL_SECONDS)
threading.Thread(target=run, name="adachi-preview-cleanup", daemon=True).start()
+85
View File
@@ -0,0 +1,85 @@
"""Bounded in-memory comparison PNGs. No preview is written to disk."""
from collections import OrderedDict
import hashlib
import os
import secrets
import threading
def _limit():
try:
mib = int(os.environ.get("ADACHI_COMPARE_MEMORY_MIB", "512"))
except ValueError:
mib = 512
return max(64, min(4096, mib)) * 1024 * 1024
class PreviewStore:
def __init__(self, limit=None, max_batches=512):
self.limit = _limit() if limit is None else limit
self.max_batches = max(1, int(max_batches))
self.nonce = secrets.token_hex(12)
self.epoch = 0
self.size = 0
self.groups = OrderedDict()
self.keys = {}
self.latest = {}
self.lock = threading.RLock()
def fingerprint(self):
with self.lock:
return f"{self.nonce}:{self.epoch}"
def put_batch(self, images, owner):
"""Atomically retain a whole comparison batch, or explicitly reject it."""
if not images or any(not image for image in images):
raise ValueError("The comparison batch must contain nonempty images.")
total = sum(len(image) for image in images)
if total > self.limit:
raise ValueError("The comparison batch exceeds the in-memory preview limit. Reduce batch size or increase ADACHI_COMPARE_MEMORY_MIB (64–4096 MiB).")
digest = hashlib.sha256()
for image in images:
digest.update(len(image).to_bytes(8, "big")); digest.update(image)
signature = digest.digest()
owner = str(owner)
with self.lock:
before = self.epoch
last = self.latest.get(owner)
# Cache fingerprinting happens before execution. If eviction of an
# unrelated or older batch caused a rerun but pixels are unchanged,
# keep the existing descriptors and let the fingerprint stabilise.
if last and last[1] != before and last[0] in self.groups:
group = self.groups[last[0]]
if group["signature"] == signature:
self.groups.move_to_end(last[0])
self.latest[owner] = (last[0], before)
return list(group["descriptors"])
while self.groups and (self.size + total > self.limit or len(self.groups) >= self.max_batches):
group_id, group = self.groups.popitem(last=False)
self.size -= group["size"]
for key in group["keys"]:
self.keys.pop(key, None)
if self.latest.get(group["owner"], (None,))[0] == group_id:
self.latest.pop(group["owner"], None)
self.epoch += 1
group_id = secrets.token_urlsafe(24)
keys = [secrets.token_urlsafe(24) for _ in images]
descriptors = [{"type": "adachi_memory", "subfolder": "", "filename": key + ".png"} for key in keys]
self.groups[group_id] = {"owner": owner, "size": total, "keys": keys, "signature": signature, "descriptors": descriptors}
for key, image in zip(keys, images):
self.keys[key] = (group_id, image)
self.size += total
self.latest[owner] = (group_id, before)
return list(descriptors)
def get(self, key):
with self.lock:
found = self.keys.get(key)
if not found:
return None
self.groups.move_to_end(found[0])
return found[1]
PREVIEWS = PreviewStore()
+18 -3
View File
@@ -1,11 +1,26 @@
[project]
name = "comfyui-adachi-workbench"
version = "0.2.0"
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
name = "adachi-inpaint-workbench"
version = "2.1.1"
description = "Inline mask painting, crop blending and a standalone inpainting workbench for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
readme = "README.md"
authors = [{ name = "Adachi" }]
dependencies = ["numpy", "Pillow", "torch"]
classifiers = [
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
]
[project.urls]
Repository = "https://github.com/adachi0130/Adachi-Inpaint-Workbench"
Documentation = "https://github.com/adachi0130/Adachi-Inpaint-Workbench#readme"
"Bug Tracker" = "https://github.com/adachi0130/Adachi-Inpaint-Workbench/issues"
[tool.comfy]
PublisherId = "adachi0130"
DisplayName = "Adachi-Inpaint-Workbench"
[tool.adachi-workbench]
frontend = "web"
+25
View File
@@ -0,0 +1,25 @@
# Manager / Registry publishing
The node is registered under publisher `adachi0130` in [Comfy Registry](https://registry.comfy.org/nodes/adachi-inpaint-workbench). Version visibility and installation can depend on Registry validation, so check the current status there.
## Manager listing
[manager-entry.json](manager-entry.json) records the legacy list entry submitted to [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager).
For current registration requirements, follow the [official Manager instructions](https://github.com/Comfy-Org/ComfyUI-Manager#how-to-register-your-custom-node-into-comfyui-manager) and the Registry publishing guide below.
You can also install through the Manager's Git URL option, using `https://github.com/adachi0130/Adachi-Inpaint-Workbench`.
## Comfy Registry
Per the [official publishing guide](https://docs.comfy.org/registry/publishing), publishing requires the registered Publisher and its API key.
1. Sign in at [Comfy Registry](https://registry.comfy.org/) as the owner of publisher `adachi0130`.
2. Check `[tool.comfy].PublisherId` in `pyproject.toml` (currently `adachi0130`) and confirm it matches the Publisher you own before publishing.
3. In the repository Settings → Secrets and variables → Actions, add `REGISTRY_ACCESS_TOKEN` with the Registry publishing API key.
4. Run **Publish to Comfy Registry** manually from the Actions tab and check the publish and platform validation results.
The publishing workflow is manual-only. Check its result and the Registry version status after publishing.
A Registry version cannot be overwritten once published, so later releases have to bump the version in `pyproject.toml`.
## 2.0.1 verification
After publishing 2.0.1, check both the Actions result and the new version's status in the [public Registry API](https://api.comfy.org/nodes/adachi-inpaint-workbench/versions?include_status_reason=true). A successful publishing job confirms upload, not Registry approval or Manager availability. Previous versions received automated scan flags; [scan review notes](SECURITY-REVIEW.md) explain the public findings and the checks required for this release. Do not report the version as approved while its status is `Pending` or `Flagged`.
+23
View File
@@ -0,0 +1,23 @@
# Registry scan review notes
Publication and Registry approval are separate steps. Check the [public version API, including scan reasons](https://api.comfy.org/nodes/adachi-inpaint-workbench/versions?include_status_reason=true) after publishing **2.0.0**; a successful upload does not establish an `Active` version or Manager availability. The publisher cannot change the Registry review status through this release workflow.
## Previous scan findings
On 2026-11-10, **2.0.0** is also `NodeVersionStatusFlagged`, with extraction `pending`. Its six `info` records match the configuration read, `Function.bind()` in `web/compare_node.js`, `web/inline_crop.js`, `web/result_recovery.js` and `web/workspace_ui.js`, and the upload send in `web/upload_transport.js`. The same code-purpose explanations below apply; no official approval is claimed.
On 2026-11-09, versions **1.0.0** and **1.0.1** were `NodeVersionStatusFlagged`, with `comfy_node_extract_status: pending`. Their public scan reasons contained the following three `info` findings. Line numbers refer to those releases.
| File / line | Reported rule | Purpose and assessment |
| --- | --- | --- |
| `preview_store.py:12` | `python_environment_manipulation` | Reads `ADACHI_COMPARE_MEMORY_MIB` to bound the in-memory image cache. It reads one documented setting and does not modify environment variables. We infer that the rule is flagging a benign configuration read. |
| `web/inline_crop.js:47` (also 55, 92 and 93) | `python_network_operations` | JavaScript `Function.bind()` retains the editor object as `this` when wrapping upload, flush and pointer handlers. This does not open a network socket; we infer a cross-language pattern false positive. |
| `web/upload_transport.js:15` | `python_network_operations` | `XMLHttpRequest.send()` uploads a user-selected image to ComfyUI's own `/upload/image` route through its API URL, allowing real upload progress. Network activity here is intentional; we infer that the finding reflects required functionality rather than data exfiltration. |
These assessments describe the matched code, not an official security clearance or a comprehensive audit. Required functionality remains readable; no code is obfuscated to avoid scanning. Development tests and review documents are excluded from the Registry archive by `.comfyignore`.
## Release checks
- Inspect the new version's actual `status` and any `status_reason`; do not assume the previous findings describe a new release.
- If flagged, retain the version and artifact identifiers and provide the relevant code explanation for manual review. Registry approval remains an external decision.
- Follow the [official Registry quality and security standards](https://docs.comfy.org/registry/standards), including the prohibitions on code obfuscation, `eval` / `exec` and runtime package installation.
+10
View File
@@ -0,0 +1,10 @@
{
"author": "adachi0130",
"title": "Adachi-Inpaint-Workbench",
"reference": "https://github.com/adachi0130/Adachi-Inpaint-Workbench",
"files": [
"https://github.com/adachi0130/Adachi-Inpaint-Workbench"
],
"install_type": "git-clone",
"description": "Paint the inpaint mask directly on the node canvas instead of a modal mask editor, with no save/load round-trip per stroke. The connected inpaint node hosts the live painting tools and WebUI-style sampling controls, and supports whole-image or padded masked-area sampling with original-resolution compositing, Alt temporary erase, Ctrl+wheel brush size, hover image comparison and one-click adoption of a result as the next source image. MIT licensed; reuses ComfyUI's NumPy, Pillow and PyTorch."
}
+3
View File
@@ -0,0 +1,3 @@
numpy
Pillow
torch
+7
View File
@@ -0,0 +1,7 @@
import assert from 'node:assert/strict';
import {ImageCompareView} from '../web/compare.js';
const view=Object.create(ImageCompareView.prototype),events=[];
Object.assign(view,{ready:true,pendingOutputs:0,targetSelect:{value:'7'},results:[{}],index:0,progressBox:{hidden:true},refreshTargets(){},updateButton(){},status(s,e){events.push(['status',s,e]);},loading(s,v){this.progressBox.hidden=false;events.push(['progress',s,v]);},options:{async onAdopt(id,image,{onProgress}){onProgress({phase:'download',loaded:20,total:100});onProgress({phase:'upload',loaded:30,total:100});onProgress({phase:'processing'});}}});
await view.adopt();assert.equal(view.busy,false);assert.equal(view.progressBox.hidden,true);const progress=events.filter(e=>e[0]==='progress');assert.equal(progress.length,4);assert.equal(progress[1][2][0].loaded,20);assert.equal(progress[2][2][0].loaded,30);
view.options.onAdopt=async()=>{throw Error('network failure')};await view.adopt();assert.equal(view.progressBox.hidden,true);assert.equal(view.busy,false);assert(events.some(e=>e[0]==='status'&&e[1]==='network failure'&&e[2]));
console.log('Adoption progress: actual download/upload bytes, loading phase and failed retry cleanup passed');
+10 -11
View File
@@ -61,18 +61,17 @@ class BackendTests(unittest.TestCase):
def test_comparison_previews_and_result_passthrough(self):
image, _ = source()
result = image.repeat_interleave(3, 0)
calls = []
class Preview:
def save_images(self, images, filename_prefix):
calls.append((images.clone(), filename_prefix))
return {"ui": {"images": [{"filename": f"{filename_prefix}_{i}.png", "subfolder": "", "type": "temp"} for i in range(len(images))]}}
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(PreviewImage=Preview)}):
output = backend.AdachiImageCompare().compare(image, result)
output = backend.AdachiImageCompare().compare(image, result, unique_id="test")
self.assertTrue(torch.equal(output["result"][0], result))
self.assertEqual(len(output["ui"]["adachi_originals"]), 1)
self.assertEqual(len(output["ui"]["adachi_results"]), 3)
self.assertTrue(torch.equal(calls[0][0], image))
self.assertTrue(torch.equal(calls[1][0], result))
for preview in output["ui"]["adachi_originals"] + output["ui"]["adachi_results"]:
self.assertEqual(preview["type"], "adachi_memory")
raw = backend.PREVIEWS.get(preview["filename"][:-4])
with Image.open(io.BytesIO(raw)) as png:
actual = np.asarray(png)
expected = np.clip(255 * image[0].numpy(), 0, 255).astype(np.uint8)
np.testing.assert_array_equal(actual, expected)
def test_comparison_rejects_crop_and_incompatible_batch(self):
image, _ = source(batch=2)
@@ -177,13 +176,13 @@ class BackendTests(unittest.TestCase):
def test_embedded_source_and_mask_convention(self):
rgba = Image.new("RGBA", (8, 4), (10, 20, 30, 255))
rgba.putpixel((0, 0), (10, 20, 30, 0))
pixels, alpha_mask = backend.AdachiInlineMask().load(source_data=data_url(rgba))
pixels, alpha_mask, _ = backend.AdachiInlineMask().load(source_data=data_url(rgba))
self.assertEqual(tuple(pixels.shape), (1, 4, 8, 3))
self.assertEqual(float(alpha_mask[0, 0, 0]), 1)
self.assertEqual(float(alpha_mask[0, 1, 1]), 0)
blackwhite = Image.new("L", (8, 4), 0)
blackwhite.putpixel((2, 1), 255)
_, explicit_mask = backend.AdachiInlineMask().load(source_data=data_url(rgba), mask_data=data_url(blackwhite))
_, explicit_mask, _ = backend.AdachiInlineMask().load(source_data=data_url(rgba), mask_data=data_url(blackwhite))
self.assertEqual(float(explicit_mask[0, 1, 2]), 1)
self.assertEqual(float(explicit_mask[0, 0, 0]), 0)
+21
View File
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
class Element {
constructor(tag){this.tag=tag;this.style={};this.dataset={};this.classList={toggle(){}};this.children=[];this.width=0;this.height=0;this.value='';}
append(...children){this.children.push(...children)} prepend(...children){this.children.unshift(...children)} setAttribute(){} hasPointerCapture(){return false} setPointerCapture(){}
getContext(){return {save(){},restore(){},beginPath(){},rect(){},clip(){},arc(){},fill(){},moveTo(){},lineTo(){},stroke(){},translate(){},clearRect(){},drawImage(){},getImageData(){return {data:[12,34,56,255]}}};}
toDataURL(){return 'data:image/png;base64,paint';}
}
globalThis.document={createElement:tag=>new Element(tag)};globalThis.ResizeObserver=class {observe(){} disconnect(){}};
globalThis.Image=class {constructor(){this.width=16;this.height=12;} async decode(){if(this.src==='bad')throw Error('bad image');}};
const {ColorPaint}=await import('../web/color_paint.js');
const editor={image:{width:16,height:12},ready:true,view:'overlay',brushSize:8,brushOpacity:100,stage:new Element('div'),root:new Element('div'),canvas:new Element('canvas'),options:{drawSource:()=>false},point:e=>({x:e.x,y:e.y}),pointerDown(){},pointerMove(){},pointerUp(){},schedule(){},status(){}};
const crop={mode:'mask',bar:new Element('div'),region:{locked:false}},widget={value:''};let changes=0;
const paint=new ColorPaint(editor,crop,widget,()=>changes++);assert.equal(crop.bar.children[0],paint.controls);paint.button.onclick({preventDefault(){},stopPropagation(){}});
const event={button:0,pointerId:1,x:2,y:3,preventDefault(){},stopPropagation(){}};
editor.pointerDown(event);assert.equal(editor.pointerId,1);editor.pointerUp(event);assert.equal(editor.pointerId,null);assert.match(widget.value,/data:image\/png/);assert.equal(changes,1);
const saved=widget.value;editor.pointerDown({...event,altKey:true});assert.equal(paint.color,'#0c2238');assert.equal(widget.value,saved);
await assert.rejects(()=>paint.prepareRestore('bad',16,12));await assert.rejects(()=>paint.prepareRestore('anything',17,12));
const loaded=await paint.prepareRestore('anything',16,12);paint.commitRestore(loaded,16,12);assert.equal(paint.undo.length,0);
paint.clearRegion({x:0,y:0,width:4,height:4});assert.equal(changes,2);paint.clear();assert.equal(widget.value,'');
widget.value='old';let finish;paint.prepareRestore=()=>new Promise(resolve=>{finish=resolve});const restoring=paint.restore('old');paint.clear();finish(new Image());assert.equal(await restoring,false);assert.equal(paint.layer.width,0);assert.equal(paint.restoring,false);
console.log('Color painting: stroke commits, Alt sampling, immutable restore validation and region clearing pass');
+23
View File
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { appendComparison } from "../web/compare_history.js";
const first = {originals: ["source-a"], results: ["result-a"]};
const second = appendComparison(first, {originals: ["source-b"], results: ["result-b", "result-c"]});
assert.deepEqual(second, {originals: ["source-a", "source-b", "source-b"], results: ["result-a", "result-b", "result-c"]});
assert.deepEqual(first, {originals: ["source-a"], results: ["result-a"]}, "Do not mutate saved history");
const legacy = {originals: ["a", "b"], results: [1, 2, 3, 4]};
assert.deepEqual(appendComparison(legacy, {originals: ["c"], results: [5]}), {
originals: ["a", "a", "b", "b", "c"], results: [1, 2, 3, 4, 5],
});
assert.deepEqual(appendComparison(undefined, first), first);
assert.deepEqual(appendComparison(first, {}), first);
assert.deepEqual(appendComparison({}, {results: ["orphan"]}), {originals: [], results: []});
assert.deepEqual(appendComparison(JSON.parse(JSON.stringify(second)), first).results, ["result-a", "result-b", "result-c"], "Cached execution replay must not count twice");
const preview = {filename: "preview_001.png", subfolder: "", type: "temp"};
const one = {originals: ["source-a"], results: [preview]};
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview}]}).results.length, 1);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, filename: "preview_002.png"}]}).results.length, 2);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, subfolder: "other"}]}).results.length, 2);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, type: "output"}]}).results.length, 2);
assert.deepEqual(appendComparison({originals: ["source-a", "source-a"], results: [preview, {...preview}]}), one, "Repair duplicates already saved in workflows");
console.log("Comparison history checks passed");
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import {CompareViewport} from '../web/compare_viewport.js';
import {ImageCompareView} from '../web/compare.js';
const v=new CompareViewport();
const size=[1000,600,800,400],at=[235,417];
const original=v.transform(...size);
assert.equal(original.scale,1.25);assert.equal(original.y,50);
const pixel=t=>[(at[0]-t.x)/t.scale,(at[1]-t.y)/t.scale];
const initial=pixel(original);
v.zoomAt(...size,...at,-120);
assert.ok(v.zoom>1);const enlarged=v.transform(...size);
assert.ok(Math.abs(pixel(enlarged)[0]-initial[0])<1e-9);
assert.ok(Math.abs(pixel(enlarged)[1]-initial[1])<1e-9,'wheel zoom keeps the image point under the pointer');
v.pan(20,-10);const moved=v.transform(...size);assert.equal(moved.x,enlarged.x+20);assert.equal(moved.y,enlarged.y-10);
for(let i=0;i<500;i++)v.zoomAt(...size,...at,-120);assert.equal(v.zoom,16);
for(let i=0;i<1000;i++)v.zoomAt(...size,...at,120);assert.equal(v.zoom,.1);
v.fit();assert.deepEqual(v.transform(...size),original);
v.zoomAt(0,0,800,400,0,0,120);assert.equal(v.zoom,1);
console.log('Compare viewport: pointer-anchored zoom, panning, limits, fit and zero-size protection pass');
{
const draws=[];const context={setTransform(){},clearRect(){},save(){},restore(){},beginPath(){},rect(){},clip(){},drawImage(image){draws.push(image);}};
const view=Object.create(ImageCompareView.prototype),before={width:800,height:400},after={width:800,height:400};
Object.assign(view,{viewport:new CompareViewport(),workspaceZoom:false,displayMode:'compare',ready:true,before,after,hovered:true,split:.5,version:1,divider:{style:{}},canvas:{width:0,height:0,clientWidth:1000,getBoundingClientRect:()=>({width:1000,height:600,left:0,top:0}),getContext:()=>context},options:{onGraphWheel:()=>graphWheels++},schedule:()=>schedules++});
let graphWheels=0,schedules=0;globalThis.devicePixelRatio=1;
const event={deltaY:-120,deltaMode:0,clientX:235,clientY:417,preventDefault(){},stopPropagation(){}};
view.wheel(event);assert.equal(graphWheels,1,'normal graph node wheel must still navigate the workflow');
view.enableWorkspaceZoom(true);view.wheel(event);assert.equal(graphWheels,1);assert.ok(view.viewport.zoom>1);
const shared=view.transform();
view.setDisplayMode('before');view.render();assert.deepEqual(draws.splice(0),[before]);assert.deepEqual(view.transform(),shared);
view.setDisplayMode('after');view.render();assert.deepEqual(draws.splice(0),[after]);assert.deepEqual(view.transform(),shared);
view.setDisplayMode('compare');view.render();assert.deepEqual(draws.splice(0),[after,before]);
view.fitView();assert.equal(view.viewport.zoom,1);assert.ok(schedules>0);
view.viewport.pan(50,30);view.enableWorkspaceZoom(false);assert.equal(view.displayMode,'compare');assert.equal(view.viewport.panX,0);assert.equal(view.viewport.panY,0);
}
console.log('Compare integration: graph wheel routing, workspace zoom, shared before/after transform, display pixels and teardown reset pass');
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import {syncCropDimensions,syncCurrentDimensions} from '../web/parameter_bridge.js';
const nodes=new Map(),links={},graph={links,getNodeById:id=>nodes.get(id),_nodes:[]};
const source={id:1,type:'AdachiInlineMask',graph};nodes.set(1,source);graph._nodes.push(source);
const make=(id,type,linkedWidth=false)=>{
const n={id,type,graph,inputs:[{name:'image',link:id},...(linkedWidth?[{name:'width',link:999}]:[])],widgets:[{name:'width',value:640},{name:'height',value:640}]};
nodes.set(id,n);graph._nodes.push(n);links[id]={origin_id:1};return n;
};
const sampler=make(2,'AdachiWebUIInpaint'),prepare=make(3,'AdachiInpaintPrepare',true);
syncCropDimensions(source,1024,1536);
assert.deepEqual(sampler.widgets.map(w=>w.value),[1024,1536]);assert.deepEqual(prepare.widgets.map(w=>w.value),[640,1536]);
syncCropDimensions(source,9000,17);assert.deepEqual(sampler.widgets.map(w=>w.value),[8192,64]);
source.adachiEditor={ready:true,image:{width:1001,height:1551}};
source.adachiCrop={mode:'mask',region:{locked:false}};
syncCurrentDimensions(source,sampler);
assert.deepEqual(sampler.widgets.map(w=>w.value),[1000,1552]);
assert.deepEqual(prepare.widgets.map(w=>w.value),[640,64]);
source.adachiCrop={mode:'crop',region:{locked:false,width:512,height:768}};
syncCurrentDimensions(source);assert.deepEqual(sampler.widgets.map(w=>w.value),[1000,1552]);
source.adachiCrop.region.locked=true;
syncCurrentDimensions(source);assert.deepEqual(sampler.widgets.map(w=>w.value),[512,768]);
assert.deepEqual(prepare.widgets.map(w=>w.value),[640,768]);
console.log('Resolution sync: source/crop modes, connection target, linked-input protection and alignment passed');
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { CROP_PRESETS, cropAt } from "../web/crop_geometry.js";
assert.ok(CROP_PRESETS.length <= 6);
assert.ok(CROP_PRESETS.some(([w,h]) => w === 512 && h === 512));
assert.ok(CROP_PRESETS.some(([w,h]) => w === 2048 && h === 2048));
assert.ok(CROP_PRESETS.every(([w, h]) => w % 64 === 0 && h % 64 === 0 && Math.min(w,h) >= 512 && Math.max(w,h) <= 2048));
assert.deepEqual(cropAt(2048, 3072, 1024, 1536, 400, 700), {
x: 400,
y: 700,
width: 1024,
height: 1536,
});
assert.deepEqual(cropAt(2048, 3072, 1024, 1536, 2040, 3070), {
x: 1024,
y: 1536,
width: 1024,
height: 1536,
});
assert.equal(cropAt(512, 512, 1024, 1536, 0, 0), null);
assert.equal(cropAt(2048, 3072, 1025, 1536, 0, 0), null);
assert.equal(cropAt(2048, 3072, 1024, 1536, NaN, 0), null);
for (const x of [-100, 0, 12.9, 1000, 3000])
for (const y of [-1, 0, 2200, 4000]) {
const r = cropAt(2048, 3072, 1024, 1536, x, y);
assert.ok(
r.x >= 0 && r.y >= 0 && r.x + r.width <= 2048 && r.y + r.height <= 3072,
);
}
console.log("Crop presets, source coordinates and edge clamping passed");
+119
View File
@@ -0,0 +1,119 @@
import base64
import importlib.util
import io
import json
from pathlib import Path
import tempfile
import types
import unittest
from unittest.mock import patch
import numpy as np
from PIL import Image
import torch
spec=importlib.util.spec_from_file_location('inline_crop_tests',Path(__file__).resolve().parents[1]/'backend.py')
backend=importlib.util.module_from_spec(spec);spec.loader.exec_module(backend)
def url(image):
b=io.BytesIO();image.save(b,format='PNG');return 'data:image/png;base64,'+base64.b64encode(b.getvalue()).decode()
class InlineCropTests(unittest.TestCase):
def setUp(self):
pixels=np.arange(96*128*3,dtype=np.uint8).reshape(96,128,3)
self.source=url(Image.fromarray(pixels));self.original=torch.from_numpy(pixels.astype(np.float32)/255)[None]
self.region=dict(x=32,y=16,width=64,height=64,locked=True,job='region-a')
self.state=json.dumps(self.region)
def test_legacy_mask_mode_ignores_result_and_keeps_original_ports(self):
image,mask,full=backend.AdachiInlineMask().load(source_data=self.source,result=torch.ones(1,64,64,3))
self.assertTrue(torch.equal(image,self.original));self.assertTrue(torch.equal(image,full));self.assertEqual(tuple(mask.shape),(1,96,128))
def test_crop_source_and_generation_mask_are_sliced_at_exact_coordinates(self):
pixels=np.zeros((96,128),dtype=np.uint8);pixels[20:24,35:40]=255
image,mask,full=backend.AdachiInlineMask().load(source_data=self.source,mask_data=url(Image.fromarray(pixels)),edit_mode='crop',crop_state=self.state)
self.assertTrue(torch.equal(image,self.original[:,16:80,32:96]));self.assertTrue(torch.equal(full,self.original))
self.assertEqual(mask.sum().item(),20);self.assertEqual(mask[0,4,3].item(),1)
def test_color_paint_composites_after_crop_layer_before_crop_without_touching_mask(self):
paint=np.zeros((96,128,4),dtype=np.uint8)
paint[20,35]=[255,0,0,255]
paint[21,36]=[0,255,0,128]
paint[4,5]=[0,0,255,255]
paint_data=url(Image.fromarray(paint))
mask=np.zeros((96,128),dtype=np.uint8);mask[20,35]=255
with patch.object(backend,'_load_layer',return_value=torch.ones(1,64,64,3)):
image,m,full=backend.AdachiInlineMask().load(source_data=self.source,mask_data=url(Image.fromarray(mask)),edit_mode='crop',crop_state=self.state,layer_image='layer.png',paint_data=paint_data)
self.assertTrue(torch.equal(image,full[:,16:80,32:96]))
self.assertTrue(torch.equal(full[0,20,35],torch.tensor([1.,0.,0.])))
self.assertTrue(torch.allclose(full[0,21,36],torch.tensor([127/255,1.,127/255])))
self.assertTrue(torch.equal(full[0,4,5],torch.tensor([0.,0.,1.])))
self.assertEqual(m.sum().item(),1)
flat,_,_=backend.AdachiInlineMask().load(source_data=self.source,paint_data=paint_data)
self.assertTrue(torch.equal(flat[0,20,35],torch.tensor([1.,0.,0.])))
self.assertNotEqual(backend.AdachiInlineMask.IS_CHANGED(source_data=self.source),backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,paint_data=paint_data))
with self.assertRaisesRegex(ValueError,'dimensions must match'):
backend.AdachiInlineMask().load(source_data=self.source,paint_data=url(Image.new('RGBA',(1,1))))
def test_layer_blend_preserves_outside_and_supports_batch_and_soft_edges(self):
alpha=np.zeros((64,64),dtype=np.uint8);alpha[:,:32]=255;alpha[:,32:48]=128
result=torch.stack([torch.zeros(64,64,3),torch.ones(64,64,3)])
blended=backend._blend_layer(self.original,result,url(Image.fromarray(alpha)),self.region)
self.assertEqual(tuple(blended.shape),(2,96,128,3))
outside=torch.ones(96,128,dtype=torch.bool);outside[16:80,32:96]=False
for i in range(2):
self.assertTrue(torch.equal(blended[i][outside],self.original[0][outside]))
self.assertTrue(torch.equal(blended[i,16:80,32:64],result[i,:,:32]))
self.assertTrue(torch.equal(blended[i,16:80,80:96],self.original[0,16:80,80:96]))
self.assertTrue(torch.allclose(blended[i,16:80,64:80],self.original[0,16:80,64:80]*(127/255)+result[i,:,32:48]*(128/255)))
def test_result_sink_persists_only_one_content_addressed_layer(self):
with tempfile.TemporaryDirectory() as folder,patch.dict('sys.modules',{'folder_paths':types.SimpleNamespace(get_input_directory=lambda:folder)}):
args=dict(result=torch.ones(2,64,64,3),source_data=self.source,crop_state=self.state,target='42')
a=backend.AdachiLayerCommit().compose(**args);b=backend.AdachiLayerCommit().compose(**args)
self.assertEqual(a['ui'],b['ui']);self.assertEqual(a['ui']['adachi_layer'][0]['target'],'42')
self.assertEqual(a['ui']['adachi_layer'][0]['job'],'region-a')
self.assertEqual(len(list(Path(folder).rglob('*.png'))),1)
descriptor=a['ui']['adachi_layer'][0]['image'];name=descriptor['subfolder']+'/'+descriptor['filename']
image,_,full=backend.AdachiInlineMask().load(source_data=self.source,edit_mode='crop',crop_state=self.state,layer_image=name)
self.assertTrue(torch.equal(image,torch.ones(1,64,64,3)));self.assertTrue(torch.equal(full,a['result'][0][:1]))
def test_legacy_layer_sink_applies_paint_above_generated_layer(self):
paint=np.zeros((96,128,4),dtype=np.uint8);paint[20,35]=[255,0,0,255]
with tempfile.TemporaryDirectory() as folder,patch.dict('sys.modules',{'folder_paths':types.SimpleNamespace(get_input_directory=lambda:folder)}):
output=backend.AdachiLayerCommit().compose(torch.ones(1,64,64,3),source_data=self.source,crop_state=self.state,paint_data=url(Image.fromarray(paint)))['result'][0]
self.assertTrue(torch.equal(output[0,20,35],torch.tensor([1.,0.,0.])))
self.assertTrue(torch.equal(output[0,20,36],torch.ones(3)))
def test_paint_input_file_preserves_transparency_and_changes_fingerprint(self):
pixels=np.zeros((96,128,4),dtype=np.uint8);pixels[20,35]=[255,0,0,255]
with tempfile.TemporaryDirectory() as folder,patch.dict('sys.modules',{'folder_paths':types.SimpleNamespace(get_input_directory=lambda:folder)}):
path=Path(folder)/'paint.png';Image.fromarray(pixels).save(path)
a=backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,paint_data='paint.png')
result,_,_=backend.AdachiInlineMask().load(source_data=self.source,paint_data='paint.png')
self.assertTrue(torch.equal(result[0,20,35],torch.tensor([1.,0.,0.])))
path.unlink()
self.assertTrue(np.isnan(backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,paint_data='paint.png')))
self.assertIsInstance(a,str)
def test_invalid_regions_or_result_sizes_fail_before_writing(self):
for change in ({'x':-1},{'y':80},{'width':65},{'height':0},{'x':True}):
with self.subTest(change=change),self.assertRaises(ValueError):
backend._blend_layer(self.original,torch.ones(1,64,64,3),'',{**self.region,**change})
with patch.object(backend,'_store_layer') as store:
with self.assertRaisesRegex(ValueError,'must match'):
backend.AdachiLayerCommit().compose(torch.ones(1,32,64,3),source_data=self.source,crop_state=self.state)
store.assert_not_called()
def test_mask_mode_removes_feedback_even_when_connected(self):
data={'prompt':{'1':{'class_type':'AdachiInlineMask','inputs':{'result':['2',0]}},'2':{'class_type':'ImageInvert','inputs':{'image':['1',0]}}}}
backend.normalize_exclusive_prompt(data)
self.assertNotIn('result',data['prompt']['1']['inputs']);self.assertEqual(len(data['prompt']),2)
def test_crop_mode_has_no_result_socket_or_automatic_sink(self):
self.assertNotIn('result', backend.AdachiInlineMask.INPUT_TYPES()['optional'])
data={'prompt':{'1':{'class_type':'AdachiInlineMask','inputs':{'result':['2',0],'edit_mode':'crop','crop_state':self.state}},'2':{'class_type':'ImageInvert','inputs':{'image':['1',0]}},'3':{'class_type':'SaveImage','inputs':{'images':['1',2]}}}}
backend.normalize_exclusive_prompt(data)
self.assertEqual(len(data['prompt']),3)
self.assertNotIn('result',data['prompt']['1']['inputs'])
self.assertEqual(data['prompt']['3']['inputs']['images'],['1',2])
def test_comparison_origin_tracks_exact_generating_region(self):
prompt={'7':{'class_type':'AdachiInlineMask','inputs':{'edit_mode':'crop','crop_state':self.state}},'8':{'class_type':'Reroute','inputs':{'image':['7',0]}},'9':{'class_type':'AdachiImageCompare','inputs':{'original':['8',0]}}}
self.assertEqual(backend._comparison_origin(prompt,'9'),{'source':'7','mode':'crop','job':'region-a'})
prompt['7']['inputs']['crop_state']=json.dumps({**self.region,'job':'next-region'})
self.assertEqual(backend._comparison_origin(prompt,'9')['job'],'next-region')
self.assertIsNone(backend._comparison_origin({},'9'))
def test_fingerprint_tracks_mode_blend_and_layer(self):
first=backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,edit_mode='mask')
for extra in ({'edit_mode':'crop'},{'blend_data':'new'},{'layer_image':'changed.png'}):
self.assertNotEqual(first,backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,**extra))
if __name__=='__main__':unittest.main()
+126
View File
@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import { canStoreDraft, prepareInlineDraft, syncInlineWidgets, inlineWidgetNames } from "../web/draft_guard.js";
class Storage {
constructor(limit = 10000) {
this.limit = limit;
this.data = new Map();
this.removed = [];
}
getItem(k) {
return this.data.get(k) ?? null;
}
setItem(k, v) {
const used = [...this.data].reduce(
(n, [key, value]) => n + (key === k ? 0 : value.length),
0,
);
if (used + v.length > this.limit)
throw Object.assign(new Error("full"), { name: "QuotaExceededError" });
this.data.set(k, v);
}
removeItem(k) {
this.removed.push(k);
this.data.delete(k);
}
}
const storage = new Storage();
storage.setItem("unrelated", "keep");
assert.equal(canStoreDraft({ nodes: [] }, { storage }), true);
assert.equal(canStoreDraft({ data: "x".repeat(20000) }, { storage }), false);
assert.equal(storage.getItem("unrelated"), "keep");
assert.ok(storage.removed.every((key) => key.startsWith("Adachi.DraftProbe:")));
assert.equal(storage.data.size, 1);
// Replacing a draft must reuse its existing space, rather than testing two full copies.
const path = "workflows/Test.json";
let hash = 2166136261;
for (const c of path) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619);
const payloadKey = `Comfy.Workflow.Draft.v2:personal:${(hash >>> 0).toString(16).padStart(8, "0")}`;
const oldGraph = { image: "x".repeat(6000) },
oldRaw = JSON.stringify({
data: JSON.stringify(oldGraph),
updatedAt: Date.now(),
});
storage.setItem(
"Comfy.Workflow.DraftIndex.v2:personal",
JSON.stringify({ entries: { one: { path } } }),
);
storage.setItem(payloadKey, oldRaw);
assert.equal(
canStoreDraft({ ...oldGraph, mode: "crop" }, { path, storage }),
true,
);
assert.equal(storage.getItem(payloadKey), oldRaw);
assert.equal(
canStoreDraft({ image: "x".repeat(12000) }, { path, storage }),
false,
);
assert.equal(storage.getItem(payloadKey), oldRaw);
assert.equal(
canStoreDraft(
{},
{
storage: {
getItem() {
throw Error("blocked");
},
setItem() {
throw Object.assign(Error("blocked"), { name: "SecurityError" });
},
removeItem() {},
},
},
),
null,
);
console.log(
"Draft guard: quota detection, replacement credit, probe cleanup and preservation of existing storage passed",
);
const large = "data:image/png;base64," + "a".repeat(300000);
const node = () => ({ type: "AdachiInlineMask", widgets_values: ["old.png", "mask", large, "crop", "region", "blend", "layer.png"], widgets_values_named: { image: "stale.png", mask_data: "stale-mask", source_data: large, edit_mode: "mask" } });
const first = node(), nested = node();
const graph = { nodes: [first, {type:"Other", widgets_values:[large]}], definitions: {subgraphs:[{nodes:[nested]}]} };
const original = structuredClone(graph);
let uploads = 0;
assert.equal(await prepareInlineDraft(graph, { fits: g => g.nodes[0].widgets_values[2].length < 100 && g.definitions.subgraphs[0].nodes[0].widgets_values[2].length < 100, upload: async bytes => { assert.equal(bytes,large); return `source-${++uploads}.png`; } }),2);
assert.equal(uploads,2);
for (const n of [first,nested]) {
assert.equal(n.widgets_values[2],"");
assert.equal(n.widgets_values_named.source_data,"");
assert.equal(n.widgets_values_named.image,n.widgets_values[0]);
assert.equal(n.widgets_values_named.mask_data,"mask");
assert.deepEqual(n.widgets_values.slice(3),["crop","region","blend","layer.png"]);
}
assert.deepEqual(graph.nodes[1],original.nodes[1]);
for (const fits of [() => true, () => null]) {
const g = {nodes:[node()]};
assert.equal(await prepareInlineDraft(g,{fits,upload:()=>{throw Error("unexpected upload")}}),0);
assert.equal(g.nodes[0].widgets_values[2],large);
}
const failure = {nodes:[node()]};
await assert.rejects(prepareInlineDraft(failure,{fits:()=>false,upload:async()=>{throw Error("upload failed")}}),/upload failed/);
assert.equal(failure.nodes[0].widgets_values[2],large);
assert.equal(failure.nodes[0].widgets_values[0],"old.png");
const namedOnly = {nodes:[{type:"AdachiInlineMask", widgets_values_named:{source_data:large,mask_data:"keep"}}]};
await prepareInlineDraft(namedOnly,{fits:()=>false,upload:async()=>"named.png"});
assert.equal(namedOnly.nodes[0].widgets_values_named.source_data,"");
assert.equal(namedOnly.nodes[0].widgets_values_named.mask_data,"keep");
const painted={nodes:[{type:'AdachiInlineMask',widgets_values:['file.png','mask','','crop','region','blend','layer.png',large],widgets_values_named:{paint_data:'stale'}}]};
await prepareInlineDraft(painted,{fits:g=>g.nodes[0].widgets_values[7].length<100,upload:async data=>{assert.equal(data,large);return 'adachi-inpaint/paint.png';}});
assert.equal(painted.nodes[0].widgets_values[7],'adachi-inpaint/paint.png');
assert.equal(painted.nodes[0].widgets_values_named.paint_data,'adachi-inpaint/paint.png');
assert.equal(painted.nodes[0].widgets_values[0],'file.png');
const failPaint={nodes:[{type:'AdachiInlineMask',widgets_values:['file.png','mask','','crop','region','blend','layer.png',large]}]};
await assert.rejects(prepareInlineDraft(failPaint,{fits:()=>false,upload:async()=>{throw Error('offline')}}),/offline/);
assert.equal(failPaint.nodes[0].widgets_values[7],large);
const widgets=inlineWidgetNames.map((name,index)=>({name,value:`live-${index}`}));
widgets.splice(1,0,{name:"unrelated",value:"untouched"});
const snapshot={widgets_values:Array(8).fill("old"),widgets_values_named:{source_data:large,unrelated:"keep"}};
syncInlineWidgets(snapshot,widgets);
assert.equal(snapshot.widgets_values[3],"live-2");
assert.equal(snapshot.widgets_values[1],"old");
assert.equal(snapshot.widgets_values_named.source_data,"live-2");
assert.equal(snapshot.widgets_values_named.unrelated,"keep");
const arrayOnly={widgets_values:[]}; syncInlineWidgets(arrayOnly,widgets);
assert.equal(arrayOnly.widgets_values_named,undefined);
console.log("Draft preflight: root/subgraph sources, dual snapshots, unchanged masks/layers, fitting sources and failed uploads passed");
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { branchIsEnabled } from "../web/exclusive_routes.js";
const nodes = new Map(), links = new Map();
const graph = {getNodeById: id => nodes.get(id), getLink: id => links.get(id)};
let sequence = 0;
function node(type, options = {}) {
const value = {id: ++sequence, type, mode: 0, inputs: [], graph, ...options};
nodes.set(value.id, value); return value;
}
function connect(source, target, name = "value") {
const id = ++sequence;
links.set(id, {origin_id: source.id, origin_slot: 0});
target.inputs.push({name, link: id});
}
const a = node("CLIPTextEncode"), b = node("CLIPTextEncode");
const sa = node("SetNode", {isVirtualNode: true}), sb = node("SetNode", {isVirtualNode: true});
connect(a, sa); connect(b, sb);
const ga = node("GetNode", {isVirtualNode: true, findSetter: () => sa});
const gb = node("GetNode", {isVirtualNode: true, findSetter: () => sb});
const switchNode = node("AdachiExclusiveSwitch");
connect(ga, switchNode, "input_a"); connect(gb, switchNode, "input_b");
const state = () => Number(branchIsEnabled(switchNode, "input_a")) | (Number(branchIsEnabled(switchNode, "input_b")) << 1);
assert.equal(state(), 3, "both real branches enabled");
sa.mode = 4; assert.equal(state(), 2, "ignored Set disables A despite enabled Get");
sa.mode = 0; sb.mode = 4; assert.equal(state(), 1, "reverse ignored Set selects A");
sb.mode = 0; a.mode = 4; assert.equal(state(), 2, "ignored encoder is found beyond Get/Set");
a.mode = 0; b.mode = 2; assert.equal(state(), 1, "muted encoder is also disabled");
b.mode = 0; ga.mode = 4; assert.equal(state(), 2, "ignored Get is disabled"); ga.mode = 0;
const reroute = node("Reroute"); connect(a, reroute); sa.inputs = []; connect(reroute, sa);
reroute.mode = 4; assert.equal(state(), 2, "ignored reroute behind Set is disabled");
reroute.mode = 0; a.mode = 4; assert.equal(state(), 2, "enabled reroute follows ignored source"); a.mode = 0;
sa.inputs = []; connect(ga, sa); assert.equal(state(), 2, "virtual routing cycle is disabled safely");
ga.findSetter = () => null; assert.equal(state(), 2, "missing setter is disabled");
const modern = node("VirtualRouter", {isVirtualNode: true, resolveVirtualOutput: () => ({node: a, slot: 0})});
const routed = node("AdachiExclusiveSwitch"); connect(modern, routed, "input_a");
assert.equal(branchIsEnabled(routed, "input_a"), true, "modern virtual output API is followed");
a.mode = 4;
assert.equal(branchIsEnabled(routed, "input_a"), false, "modern virtual output respects ignored source");
assert.equal(branchIsEnabled({...switchNode, graph: null}, "input_a"), false);
assert.equal(branchIsEnabled(switchNode, "not_connected"), false);
console.log("Exclusive routing: Get/Set, bypass/mute, reroutes, cycles and missing inputs passed");
+176
View File
@@ -0,0 +1,176 @@
"""The selector must preserve arbitrary values and never silently prefer a branch."""
import importlib.util
import copy
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 workflow(self):
return {"nodes": [
{"id": 1, "type": "CLIPTextEncode", "mode": 4},
{"id": 2, "type": "CLIPTextEncode", "mode": 0},
{"id": 3, "type": "SetNode", "mode": 4, "widgets_values": ["p_0"], "inputs": [{"link": 1}]},
{"id": 4, "type": "SetNode", "mode": 0, "widgets_values": ["p"], "inputs": [{"link": 2}]},
{"id": 5, "type": "GetNode", "mode": 0, "widgets_values": ["p_0"]},
{"id": 6, "type": "GetNode", "mode": 0, "widgets_values": ["p"]},
{"id": 532, "type": "AdachiExclusiveSwitch", "inputs": [{"name": "input_a", "link": 3}, {"name": "input_b", "link": 4}]},
], "links": [[1, 1, 0, 3, 0, "*"], [2, 2, 0, 4, 0, "*"], [3, 5, 0, 532, 0, "*"], [4, 6, 0, 532, 1, "*"]]}
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)
def test_submitted_single_connection_overrides_stale_flags(self):
for name in ("input_a", "input_b"):
prompt = {"532": {"inputs": {name: ["source", 0], "active_inputs": 3}}}
for stale in (-1, 0, 1, 2, 3):
with self.subTest(name=name, stale=stale):
args = {name: None, "active_inputs": stale, "prompt": prompt, "unique_id": "532"}
self.assertEqual(self.node.check_lazy_status(**args), [name])
value = object()
args[name] = value
self.assertIs(self.node.select(**args)[0], value)
def test_submitted_empty_or_ambiguous_connections_still_fail(self):
for inputs in ({}, {"input_a": ["a", 0], "input_b": ["b", 0]}):
with self.subTest(inputs=inputs), self.assertRaisesRegex(ValueError, "exactly one"):
self.node.check_lazy_status(active_inputs=3, prompt={"1": {"inputs": inputs}}, unique_id="1")
prompt = {"1": {"inputs": {"input_a": ["a", 0], "input_b": ["b", 0]}}}
self.assertEqual(self.node.check_lazy_status(input_a=None, input_b=None, active_inputs=2, prompt=prompt, unique_id="1"), ["input_b"])
def test_dynamic_subgraph_connection_is_authoritative(self):
class DynamicPrompt:
def get_node(self, node_id):
self_id = "subgraph:532"
if node_id != self_id:
raise KeyError(node_id)
return {"inputs": {"input_b": ["nested-source", 0]}}
self.assertEqual(self.node.check_lazy_status(input_b=None, active_inputs=3,
prompt={}, dynprompt=DynamicPrompt(), unique_id="subgraph:532"), ["input_b"])
def test_enabled_gets_follow_ignored_set_and_encoder_in_workflow(self):
workflow = self.workflow()
args = dict(input_a=None, input_b=None, active_inputs=3, unique_id="532", extra_pnginfo={"workflow": workflow})
self.assertEqual(self.node.check_lazy_status(**args), ["input_b"])
for stale in range(-1, 4):
self.assertEqual(self.node.check_lazy_status(**{**args, "active_inputs": stale}), ["input_b"])
for node in workflow["nodes"][:4]:
node["mode"] = 0 if node["mode"] == 4 else 4
self.assertEqual(self.node.check_lazy_status(**args), ["input_a"])
def test_ignored_set_alone_or_encoder_alone_disables_route(self):
for source_mode, set_mode in ((4, 0), (0, 4), (2, 0), (0, 2)):
workflow = self.workflow()
workflow["nodes"][0]["mode"] = source_mode
workflow["nodes"][2]["mode"] = set_mode
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 2)
def test_workflow_ambiguous_empty_cycle_missing_set_are_not_selected(self):
workflow = self.workflow()
for node in workflow["nodes"][:4]:
node["mode"] = 0
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 3)
for node in workflow["nodes"][:4]:
node["mode"] = 4
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 0)
workflow = self.workflow()
workflow["nodes"][2]["mode"] = 0
workflow["links"][0][1] = 5
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 2)
workflow["nodes"][4]["widgets_values"] = ["missing"]
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 2)
def test_prompt_normalization_before_cache_preserves_workflow(self):
workflow = self.workflow()
saved = copy.deepcopy(workflow)
request = {"prompt": {"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"input_a": ["a", 0], "input_b": ["b", 0], "active_inputs": 3}}},
"extra_data": {"extra_pnginfo": {"workflow": workflow}}}
self.assertIs(backend.normalize_exclusive_prompt(request), request)
self.assertEqual(request["prompt"]["532"]["inputs"], {"input_b": ["b", 0], "active_inputs": 2})
self.assertEqual(workflow, saved)
plain = {"prompt": {"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"input_a": ["a", 0]}}}}
original = copy.deepcopy(plain)
backend.normalize_exclusive_prompt(plain)
self.assertEqual(plain, original)
def test_scoped_subgraph_and_dictionary_links(self):
child = self.workflow()
child["id"] = "child"
child["links"] = [dict(zip(("id", "origin_id", "origin_slot", "target_id", "target_slot", "type"), link)) for link in child["links"]]
workflow = {"nodes": [{"id": 349, "type": "child"}], "definitions": {"subgraphs": [child]}}
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "349:532"), 2)
self.assertIsNone(backend._workflow_exclusive_flags({"workflow": workflow}, "532"))
def test_missing_selected_link_is_restored_through_get_set(self):
workflow = self.workflow()
request = {"prompt": {"2": {"class_type": "CLIPTextEncode", "inputs": {}},
"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"active_inputs": 0}}},
"extra_data": {"extra_pnginfo": {"workflow": workflow}}}
backend.normalize_exclusive_prompt(request)
self.assertEqual(request["prompt"]["532"]["inputs"], {"active_inputs": 2, "input_b": ["2", 0]})
def test_missing_subgraph_output_link_is_restored_to_flattened_node(self):
workflow = {"nodes": [
{"id": 349, "type": "child", "mode": 0},
{"id": 521, "type": "CLIPTextEncode", "mode": 4},
{"id": 532, "type": "AdachiExclusiveSwitch", "inputs": [{"name": "input_a", "link": 1}, {"name": "input_b", "link": 2}]},
], "links": [[1, 349, 2, 532, 0, "CONDITIONING"], [2, 521, 0, 532, 1, "CONDITIONING"]],
"definitions": {"subgraphs": [{"id": "child", "nodes": [{"id": 412, "type": "CLIPTextEncode", "mode": 0}],
"outputs": [{"linkIds": []}, {"linkIds": []}, {"linkIds": [2019]}],
"links": [{"id": 2019, "origin_id": 412, "origin_slot": 0, "target_id": -20, "target_slot": 2}]}]}}
request = {"prompt": {"349:412": {"class_type": "CLIPTextEncode", "inputs": {}},
"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"active_inputs": 1}}},
"extra_data": {"extra_pnginfo": {"workflow": workflow}}}
saved = copy.deepcopy(workflow)
backend.normalize_exclusive_prompt(request)
self.assertEqual(request["prompt"]["532"]["inputs"], {"active_inputs": 1, "input_a": ["349:412", 0]})
self.assertEqual(workflow, saved)
# Do not invent a source that was never submitted, or use a mismatched
# node type that happens to have the same execution ID.
for source in (None, {"class_type": "OtherNode", "inputs": {}}):
bad = copy.deepcopy(request)
bad["prompt"]["532"]["inputs"].pop("input_a")
if source is None:
bad["prompt"].pop("349:412")
else:
bad["prompt"]["349:412"] = source
backend.normalize_exclusive_prompt(bad)
self.assertNotIn("input_a", bad["prompt"]["532"]["inputs"])
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import {T, localeDict} from '../web/i18n.js';
import {WorkspaceProgress} from '../web/workspace_progress.js';
const saved = Object.fromEntries(['comfyAPI','app','navigator'].map(key => [key,Object.getOwnPropertyDescriptor(globalThis,key)]));
const settings = getLocale => ({ui:{settings:{getSettingValue(key) {
assert.equal(key,'Comfy.Locale');
return getLocale();
}}}});
try {
delete globalThis.comfyAPI; delete globalThis.app;
Object.defineProperty(globalThis,'navigator',{configurable:true,value:{language:'zh-CN',languages:['zh-CN']}});
assert.equal(T('Adopt'),'Adopt','English remains the default even for a Chinese-language browser');
assert.equal(localeDict(),null);
assert.equal(T('Missing {count} model files',{count:0}),'Missing 0 model files');
let locale='en-US';
globalThis.comfyAPI={app:{app:settings(()=>locale)}};
globalThis.app=settings(()=>'zh');
assert.equal(T('Open Adachi Workbench'),'Open Adachi Workbench','the current ComfyUI API takes priority over legacy globals');
locale='zh-CN';
assert.equal(T('Open Adachi Workbench'),'打开安达工作台');
assert.equal(T('Missing {count} model files',{count:3}),'缺少 3 份模型文件');
assert.equal(T('Settings panel {width} pixels wide',{width:360}),'右侧配置 360 像素');
assert.equal(T('Fill','enum'),'填充','existing enum contexts remain compatible');
assert.equal(T('New message {count}',{count:false}),'New message false','untranslated strings still interpolate');
assert.equal(T('Unknown {value} / {missing}',{value:'<img src=x>'}),'Unknown <img src=x> / {missing}','values remain literal text and missing placeholders remain visible');
assert.equal(T('toString'),'toString','dictionary prototype properties are not translations');
locale='zh_TW';
assert.equal(T('Workflow connections'),'工作流连接','regional locale separators both resolve to the Chinese dictionary');
const progress=new WorkspaceProgress();
progress.begin({'7':{class_type:'AdachiWebUIInpaint'}},'task');
progress.handle('progress',{prompt_id:'task',node:'7',value:2,max:10});
assert.equal(progress.label,'重绘采样(K 采样器) · 2 / 10');
locale='en';
progress.handle('progress',{prompt_id:'task',node:'7',value:3,max:10});
assert.equal(progress.label,'Inpaint sampling (KSampler) · 3 / 10','execution messages use the current setting rather than an import-time locale');
assert.equal(T('Adopt'),'Adopt','locale changes do not require module reload');
locale='fr';
assert.equal(T('Adopt'),'Adopt','unsupported locales fall back to English');
globalThis.comfyAPI={app:settings(()=>'zh')}; delete globalThis.app;
assert.equal(T('Adopt'),'采用','older ComfyUI API shape is supported');
globalThis.comfyAPI={app:{app:settings(()=>{throw Error('not initialized');})}};
globalThis.app=settings(()=>'zh');
assert.equal(T('Adopt'),'采用','a not-yet-ready modern setting can fall back to the legacy app');
delete globalThis.comfyAPI; delete globalThis.app;
assert.equal(T('Adopt'),'Adopt','an unavailable app does not break custom UI initialization');
} finally {
for(const [key,descriptor] of Object.entries(saved)) {
if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];
}
}
console.log('Localization: ComfyUI settings precedence, English fallback, live locale changes, placeholders, regional locales and enum compatibility pass');
+8
View File
@@ -0,0 +1,8 @@
import assert from 'node:assert/strict';
globalThis.location=new URL('https://example.test/app');let calls=[];
globalThis.Image=class {set src(value){this.url=value;if(!value)return;calls.push(value);queueMicrotask(()=>calls.length<3?this.onerror?.():this.onload?.());}};
const {loadImage}=await import('../web/image_load.js');
const image=await loadImage('/view?filename=test.png',{retry:true,delays:[0,0]});assert.equal(calls.length,3);assert.match(image.url,/_adachi_retry=2/);
calls=[];await assert.rejects(loadImage('data:image/png;base64,invalid',{retry:true,delays:[0,0]}));assert.equal(calls.length,1);
calls=[];const c=new AbortController();c.abort();await assert.rejects(loadImage('/view?filename=test.png',{retry:true,signal:c.signal}),{name:'AbortError'});assert.equal(calls.length,0);
console.log('Fresh uploaded image loading: bounded retry, cache bypass, non-network image and cancellation passed');
+134
View File
@@ -0,0 +1,134 @@
"""Regression coverage for image models using single-frame video-derived VAEs."""
import importlib.util
from pathlib import Path
import sys
import types
import unittest
from unittest.mock import patch
import torch
import torch.nn.functional as F
spec = importlib.util.spec_from_file_location("vae_format_backend", Path(__file__).resolve().parents[1] / "backend.py")
backend = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backend)
class VideoDerivedVAE:
latent_dim = 3
downscale_ratio = (lambda t: (t + 3) // 4, 8, 8)
def __init__(self):
self.encoded_batches = []
def spacial_compression_encode(self):
return 8
def encode(self, pixels):
self.encoded_batches.append(pixels.shape[0])
# Like Wan/Qwen VAE, an IMAGE batch would otherwise become time.
frames = F.interpolate(pixels.mean(-1)[:, None], scale_factor=1 / 8, mode="bilinear", align_corners=False)
return frames.permute(1, 0, 2, 3)[None].repeat(1, 16, 1, 1, 1)
def decode(self, samples):
image = F.interpolate(samples[:, :1, 0], scale_factor=8, mode="nearest")
return image.repeat(1, 3, 1, 1).permute(0, 2, 3, 1)[:, None]
class ImageVAEFormatTests(unittest.TestCase):
def inputs(self):
image = torch.stack([torch.full((64, 64, 3), .2), torch.full((64, 64, 3), .8)])
masks = torch.zeros(2, 64, 64)
masks[0, :, :32] = 1
masks[1, :, 32:] = 1
return image, masks
def test_5d_latents_preserve_batch_masks_compositing_and_variants(self):
for area in ("whole_image", "only_masked"):
with self.subTest(area=area):
image, masks = self.inputs()
vae, captured = VideoDerivedVAE(), []
def sample(*args, **kwargs):
captured.append(args[8])
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample)}):
result, crop, mask = backend.AdachiWebUIInpaint().sample(None, [], [], vae, image, masks,
width=64, height=64, area=area, mask_blur=0, batch_size=2)
self.assertEqual(vae.encoded_batches, [1, 1, 1, 1])
self.assertEqual(captured[0]["samples"].shape, (4, 16, 1, 8, 8))
self.assertEqual(captured[0]["noise_mask"].shape, (4, 1, 1, 8, 8))
self.assertTrue(torch.allclose(captured[0]["samples"].mean((1, 2, 3, 4)), torch.tensor([.2, .2, .8, .8])))
originals, masks = image.repeat_interleave(2, 0), masks.repeat_interleave(2, 0)
self.assertEqual(result.shape, originals.shape)
self.assertEqual(crop.shape, (4, 64, 64, 3))
self.assertEqual(mask.shape, (4, 64, 64))
self.assertTrue(torch.equal(result[masks == 0], originals[masks == 0]))
def test_5d_noise_and_zero_fill_only_modify_masked_latents(self):
image, masks = self.inputs()
captured = []
def sample(*args, **kwargs):
captured.append(args[8])
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample)}):
for fill in ("latent_noise", "latent_noise", "latent_nothing"):
backend.AdachiWebUIInpaint().sample(None, [], [], VideoDerivedVAE(), image, masks,
width=64, height=64, area="whole_image", mask_blur=0, fill=fill, seed=42)
self.assertTrue(torch.equal(captured[0]["samples"], captured[1]["samples"]))
for latent in captured:
protected = (latent["noise_mask"] == 0).expand_as(latent["samples"])
expected = torch.tensor([.2, .8])[:, None, None, None, None].expand_as(latent["samples"])
self.assertTrue(torch.allclose(latent["samples"][protected], expected[protected]))
selected = (captured[2]["noise_mask"] == 1).expand_as(captured[2]["samples"])
self.assertTrue(torch.equal(captured[2]["samples"][selected], torch.zeros_like(captured[2]["samples"][selected])))
self.assertFalse(torch.equal(captured[0]["samples"][0], captured[0]["samples"][1]))
def test_unknown_vae_wrapper_recovers_independent_batch(self):
vae = VideoDerivedVAE()
wrapper = types.SimpleNamespace(encode=vae.encode)
samples = backend._encode_still_images(wrapper, self.inputs()[0])
self.assertEqual(samples.shape, (2, 16, 1, 8, 8))
self.assertEqual(vae.encoded_batches, [2, 1, 1])
def test_native_inpaint_conditioning_keeps_5d_batch_axis(self):
image, masks = self.inputs()
original_meta = {"marker": "retained"}
conditioning = [[torch.zeros(1, 2, 3), original_meta]]
class Native:
def encode(self, positive, negative, pixels, vae, mask, noise_mask):
metadata = dict(original_meta, concat_latent_image=vae.encode(pixels), concat_mask=mask[:, None])
return [[positive[0][0], metadata]], [[negative[0][0], metadata]], {"samples": vae.encode(pixels), "native_field": True}
def sample(*args, **kwargs):
self.assertEqual(args[6][0][1]["concat_mask"].shape, (2, 1, 1, 64, 64))
self.assertEqual(args[6][0][1]["concat_latent_image"].shape, (2, 16, 1, 8, 8))
self.assertTrue(args[8]["native_field"])
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample, InpaintModelConditioning=Native)}):
backend.AdachiWebUIInpaint().sample(None, conditioning, conditioning, VideoDerivedVAE(), image, masks,
width=64, height=64, mask_blur=0, inpaint_model=True)
self.assertEqual(original_meta, {"marker": "retained"})
def test_refiner_preserves_single_frame_shape(self):
image, masks = self.inputs()
calls = []
def sample(*args, **kwargs):
calls.append(args[8]["samples"].shape)
return (args[8],)
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(common_ksampler=sample)}):
backend.AdachiWebUIInpaint().sample(None, [], [], VideoDerivedVAE(), image, masks, refiner_model=object(),
width=64, height=64, mask_blur=0)
self.assertEqual(calls, [(2, 16, 1, 8, 8)] * 2)
def test_actual_video_or_invalid_vae_outputs_fail_explicitly(self):
for samples in (torch.zeros(1, 16, 2, 8, 8), torch.zeros(1, 8, 8), "bad"):
with self.subTest(type=type(samples)), self.assertRaises(ValueError):
backend._check_image_latents(samples, 1)
with self.assertRaisesRegex(ValueError, "batch"):
backend._check_image_latents(torch.zeros(1, 4, 8, 8), 2)
vae = types.SimpleNamespace(decode=lambda latent: torch.zeros(1, 2, 64, 64, 3))
with self.assertRaisesRegex(ValueError, "multiple frames"):
backend._decode_still_images(vae, torch.zeros(1, 16, 1, 8, 8), 1)
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import {removeLegacyFeedback} from '../web/legacy_feedback.js';
const make=()=>({nodes:[{id:1,type:'AdachiInlineMask',inputs:[{name:'result',link:10},{name:'image',link:11}],outputs:[]},{id:2,type:'Source',inputs:[],outputs:[{links:[10,11]}]}],links:[[10,2,0,1,0,'IMAGE'],[11,2,0,1,1,'STRING']]});
const graph=make();graph.definitions={subgraphs:[make()]};removeLegacyFeedback(graph);
for(const g of [graph,...graph.definitions.subgraphs]){
assert.deepEqual(g.nodes[0].inputs,[{name:'image',link:11}]);assert.deepEqual(g.links,[[11,2,0,1,0,'STRING']]);assert.deepEqual(g.nodes[1].outputs[0].links,[11]);
}
removeLegacyFeedback(graph);assert.equal(graph.links.length,1);
const modern=make();modern.links=modern.links.map(([id,origin_id,origin_slot,target_id,target_slot,type])=>({id,origin_id,origin_slot,target_id,target_slot,type}));removeLegacyFeedback(modern);assert.equal(modern.links.length,1);assert.equal(modern.links[0].target_slot,0);
const orphan=make();orphan.nodes[0].inputs=[{name:'image',link:null}];removeLegacyFeedback(orphan);assert.equal(orphan.links.length,0);
console.log('Legacy feedback migration: tuple/object links, slot reindexing, nested graphs, orphan cleanup and idempotence passed');
+34
View File
@@ -0,0 +1,34 @@
"""Image-edge and dense-mask equivalence for the bounded-memory preparation path."""
import unittest
import torch
from test_backend import backend
class MaskBoundaryTests(unittest.TestCase):
def test_dense_sparse_empty_and_skinny_mask_bounds(self):
for height, width in [(1, 1), (1, 79), (81, 1), (192, 256)]:
for kind in ('empty', 'full', 'corners', 'middle'):
mask = torch.zeros(height, width)
if kind == 'full': mask.fill_(1)
elif kind == 'corners': mask[0, 0] = mask[-1, -1] = .01
elif kind == 'middle': mask[height//2, width//2] = .7
for padding in (0, 7, 2048):
box = backend._bbox(mask, padding, width/height)
x0, y0, x1, y1 = box
self.assertTrue(0 <= x0 < x1 <= width)
self.assertTrue(0 <= y0 < y1 <= height)
self.assertEqual(float(mask.sum()), float(mask[y0:y1, x0:x1].sum()))
if kind in ('full', 'corners', 'empty'):
self.assertEqual(box, (0, 0, width, height))
def test_sanitizing_never_mutates_upstream_tensors(self):
image = torch.tensor([float('nan'), float('inf'), -1.0, 2.0]*3).reshape(1, 2, 2, 3)
original = image.clone()
output = backend._image_tensor(image)
self.assertTrue(torch.equal(torch.isnan(original), torch.isnan(image)))
self.assertTrue(torch.equal(original[~torch.isnan(original)], image[~torch.isnan(image)]))
self.assertTrue(torch.isfinite(output).all())
self.assertTrue(((output >= 0) & (output <= 1)).all())
mask = torch.full((1, 2, 2), 2.0)
self.assertEqual(float(backend._mask_tensor(mask, output).max()), 1)
self.assertEqual(float(mask.min()), 2)
+131
View File
@@ -0,0 +1,131 @@
"""The public template stays portable and routes each preset independently."""
import json
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[1]
class OfficialWorkflowTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.path = ROOT / "examples" / "Adachi-Workbench.json"
cls.raw = cls.path.read_text(encoding="utf-8")
cls.workflow = json.loads(cls.raw)
cls.nodes = {node["id"]: node for node in cls.workflow["nodes"]}
cls.links = {link[0]: link for link in cls.workflow["links"]}
cls.groups = {group["id"]: group for group in cls.workflow["groups"]}
def typed(self, name):
return [node for node in self.nodes.values() if node["type"] == name]
def group_members(self, group):
x, y, width, height = group["bounding"]
return [node for node in self.nodes.values()
if x <= node["pos"][0] and y <= node["pos"][1]
and node["pos"][0] + node["size"][0] <= x + width
and node["pos"][1] + node["size"][1] <= y + height]
def test_one_complete_official_template(self):
self.assertEqual([self.path.name], sorted(p.name for p in (ROOT / "examples").glob("*.json")))
for name in ("CheckpointLoaderSimple", "EmptyLatentImage", "KSampler", "VAEDecode",
"SaveImage", "AdachiInlineMask", "AdachiWebUIInpaint", "AdachiImageCompare",
"AdachiGroupPresets"):
self.assertEqual(len(self.typed(name)), 1, name)
self.assertEqual(len(self.typed("AdachiExclusiveSwitch")), 3)
self.assertEqual(len(self.typed("CLIPTextEncode")), 4)
allowed = {"CheckpointLoaderSimple", "EmptyLatentImage", "KSampler", "VAEDecode", "SaveImage",
"CLIPTextEncode", "Note", "AdachiInlineMask", "AdachiWebUIInpaint",
"AdachiImageCompare", "AdachiGroupPresets", "AdachiExclusiveSwitch"}
self.assertTrue({node["type"] for node in self.nodes.values()} <= allowed)
def test_ids_and_reciprocal_link_types(self):
self.assertEqual(len(self.nodes), len(self.workflow["nodes"]))
self.assertEqual(len(self.links), len(self.workflow["links"]))
self.assertEqual(self.workflow["last_node_id"], max(self.nodes))
self.assertEqual(self.workflow["last_link_id"], max(self.links))
for key, source, out, target, inp, kind in self.links.values():
origin = self.nodes[source]["outputs"][out]
destination = self.nodes[target]["inputs"][inp]
self.assertIn(key, origin["links"])
self.assertEqual(destination["link"], key)
for socket in (origin, destination):
self.assertTrue(kind == socket["type"] or "*" in (kind, socket["type"]),
(key, kind, socket))
for node in self.nodes.values():
for index, socket in enumerate(node.get("inputs", [])):
if socket.get("link") is not None:
self.assertEqual(self.links[socket["link"]][3:5], [node["id"], index])
for index, socket in enumerate(node.get("outputs", [])):
for key in socket.get("links") or []:
self.assertEqual(self.links[key][1:3], [node["id"], index])
def test_presets_enable_one_branch_and_keep_common_nodes_enabled(self):
controller = self.typed("AdachiGroupPresets")[0]
state = controller["properties"]["adachi_group_presets"]
self.assertEqual(state["activeId"], "text-to-image")
self.assertEqual({preset["id"] for preset in state["presets"]}, {"text-to-image", "inpaint"})
membership = {}
for key, group in self.groups.items():
for node in self.group_members(group):
self.assertNotIn(node["id"], membership, "Mode groups must not overlap")
membership[node["id"]] = key
for node in self.nodes.values():
if node["type"] in ("AdachiExclusiveSwitch", "CheckpointLoaderSimple", "SaveImage", "AdachiGroupPresets"):
self.assertNotIn(node["id"], membership, node["type"])
for preset in state["presets"]:
self.assertEqual({value["mode"] for value in preset["groups"].values()}, {0, 4})
modes = {node["id"]: preset["groups"][f"group:{membership[node['id']]}"]["mode"]
if node["id"] in membership else node["mode"] for node in self.nodes.values()}
expected = 1 if preset["id"] == "text-to-image" else 2
for switch in self.typed("AdachiExclusiveSwitch"):
active = sum((1 << index) for index, socket in enumerate(switch["inputs"])
if modes[self.links[socket["link"]][1]] == 0)
self.assertEqual(active, expected, (preset["id"], switch["title"]))
if preset["id"] == state["activeId"]:
for node in self.nodes.values():
self.assertEqual(node["mode"], modes[node["id"]], node["title"])
def test_no_private_source_or_locale_is_bundled(self):
self.assertEqual(self.typed("CheckpointLoaderSimple")[0]["widgets_values"], ["YOUR_CHECKPOINT.safetensors"])
self.assertIsNone(re.search(r"[\u3400-\u9fff]", self.raw), "Template text must be English")
self.assertIsNone(re.search(r"[A-Za-z]:[\\/]|data:image|https?://|/Users/|/root/", self.raw))
inline = self.typed("AdachiInlineMask")[0]
self.assertEqual(inline["widgets_values"][:7], ["", "", "", "mask", "{}", "", ""])
self.assertEqual(set(self.workflow["extra"]), {"ds"})
def test_saving_text_to_image_does_not_depend_on_an_uploaded_image(self):
controller = self.typed("AdachiGroupPresets")[0]
presets = controller["properties"]["adachi_group_presets"]["presets"]
members = {node["id"]: group["id"] for group in self.groups.values()
for node in self.group_members(group)}
for preset in presets:
enabled = {key for key, node in self.nodes.items()
if (preset["groups"][f"group:{members[key]}"]["mode"]
if key in members else node["mode"]) == 0}
seen, todo = set(), [self.typed("SaveImage")[0]["id"]]
while todo:
key = todo.pop()
if key in seen:
continue
self.assertIn(key, enabled, "Active output must not use a disabled branch")
seen.add(key)
node = self.nodes[key]
sockets = node.get("inputs", [])
if node["type"] == "AdachiExclusiveSwitch":
sockets = [socket for socket in sockets if self.links[socket["link"]][1] in enabled]
self.assertEqual(len(sockets), 1)
todo.extend(self.links[socket["link"]][1] for socket in sockets if socket.get("link") is not None)
types = {self.nodes[key]["type"] for key in seen}
if preset["id"] == "text-to-image":
self.assertTrue({"KSampler", "VAEDecode", "EmptyLatentImage"} <= types)
self.assertTrue({"AdachiInlineMask", "AdachiWebUIInpaint", "AdachiImageCompare"}.isdisjoint(types))
else:
self.assertTrue({"AdachiInlineMask", "AdachiWebUIInpaint", "AdachiImageCompare"} <= types)
self.assertTrue({"KSampler", "VAEDecode", "EmptyLatentImage"}.isdisjoint(types))
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import {createPaintDraftProtection} from '../web/paint_draft.js';
const tick=()=>new Promise(r=>setTimeout(r,20));
let w={value:'data:old'},p={revision:1,drawing:false},checks=0,marks=0,errors=[],requests=[];
const protect=createPaintDraftProtection({paint:()=>p,widget:()=>w,graph:()=>({}),disposed:()=>false,persist:()=>true,fits:()=>{checks++;return false;},upload:data=>new Promise((resolve,reject)=>requests.push({data,resolve,reject})),changed:()=>marks++,onError:e=>errors.push(e.message)});
protect.schedule();await tick();assert.equal(requests.length,1);
protect.schedule();await tick();assert.equal(checks,1,'slow upload must not spin');
w.value='data:new';p.revision++;protect.schedule();await tick();assert.equal(requests.length,1);
requests[0].resolve('old.png');await tick();assert.equal(w.value,'data:new');assert.equal(requests.length,2,'new edit is uploaded after old request settles');
requests[1].resolve('new.png');await tick();assert.equal(w.value,'new.png');assert(marks>0);
p.drawing=true;w.value='data:stroke';p.revision++;const before=checks;protect.schedule();await tick();assert.equal(checks,before,'drawing must not spin');
p.drawing=false;protect.schedule();await tick();assert.equal(requests.length,3);requests[2].reject(Error('offline'));await tick();assert.equal(w.value,'data:stroke');assert.deepEqual(errors,['offline']);
protect.dispose();console.log('Paint draft: pending upload and stroke do not spin; stale upload, retry and offline retention passed');
+48
View File
@@ -0,0 +1,48 @@
"""Regression checks for comparison preview cleanup boundaries."""
import importlib.util
import os
from pathlib import Path
import tempfile
import unittest
SPEC = importlib.util.spec_from_file_location("preview_cleanup_under_test", Path(__file__).resolve().parents[1] / "preview_cleanup.py")
cleanup = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(cleanup)
class PreviewCleanupTests(unittest.TestCase):
def test_only_expired_comparison_previews_in_temp_are_removed(self):
now = 2_000_000
with tempfile.TemporaryDirectory() as root:
temp = Path(root) / "temp"
inputs = Path(root) / "input"
temp.mkdir()
inputs.mkdir()
old_original = temp / "adachi_compare_original_temp_abcde_00001_.png"
old_result = temp / "adachi_compare_result_temp_abcde_00002_.png"
recent = temp / "adachi_compare_result_temp_abcde_00003_.png"
unrelated = temp / "ComfyUI_temp_abcde_00001_.png"
wrong_name = temp / "adachi_compare_result_mine.png"
adopted = inputs / old_original.name
nested = temp / "nested"
nested.mkdir()
nested_preview = nested / old_original.name
for path in (old_original, old_result, recent, unrelated, wrong_name, adopted, nested_preview):
path.write_bytes(b"test")
os.utime(path, (now - 100_000, now - 100_000))
os.utime(recent, (now - 60, now - 60))
removed = cleanup.cleanup_comparison_previews(temp, now=now)
self.assertEqual(removed, 2)
for path in (old_original, old_result):
self.assertFalse(path.exists())
for path in (recent, unrelated, wrong_name, adopted, nested_preview):
self.assertTrue(path.exists())
def test_missing_directory_is_harmless(self):
with tempfile.TemporaryDirectory() as root:
self.assertEqual(cleanup.cleanup_comparison_previews(Path(root) / "missing"), 0)
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import {decodePreview,loadPreviewPair} from '../web/preview_images.js';
const deferred=()=>{let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return{promise,resolve,reject};};
const blob=new Blob(['PNG']);
let allocated=0;const revoked=[];
const urls={createObjectURL(){return 'blob:'+ ++allocated;},revokeObjectURL:url=>revoked.push(url)};
class ImageStub{width=512;height=512;decode(){return Promise.resolve();}}
const canceled=new AbortController();canceled.abort();
await assert.rejects(decodePreview(blob,canceled.signal,{urls,ImageClass:ImageStub}),{name:'AbortError'});
assert.equal(allocated,0,'already-canceled decoding never allocates a URL');
class Stalled extends ImageStub{decode(){return new Promise(()=>{});}}
await assert.rejects(decodePreview(blob,new AbortController().signal,{urls,ImageClass:Stalled,timeoutMs:5}),/timed out/);
assert.deepEqual(revoked,['blob:1'],'a stalled decode settles and releases the URL');
class Huge extends ImageStub{width=20000;}
await assert.rejects(decodePreview(blob,new AbortController().signal,{urls,ImageClass:Huge}),/dimensions/);
class Borderline extends ImageStub{width=5500;height=6000;}
await assert.rejects(decodePreview(blob,new AbortController().signal,{urls,ImageClass:Borderline}),/dimensions/);
const abort=new AbortController(),decode=decodePreview(blob,abort.signal,{urls,ImageClass:Stalled});abort.abort();
await assert.rejects(decode,{name:'AbortError'});
assert.equal(revoked.length,4);
// A sibling may finish after the first failure, even if an integration ignores
// cancellation. Wait for it and release its late URL before returning.
const later=deferred(),lost=[];let decodes=0;
const pending=loadPreviewPair(['a','b'],{cached:[null,null],signal:new AbortController().signal,
loadPreview:async()=>blob,decode:async()=>{if(++decodes===1)throw Error('corrupt PNG');return later.promise;},revoke:url=>lost.push(url)});
await new Promise(r=>setTimeout(r,0));later.resolve({url:'late-url'});
await assert.rejects(pending,/corrupt PNG/);assert.deepEqual(lost,['late-url']);
const bad=new Blob(['bad']),good=new Blob(['good']),cache=[bad,good];let reads=0;
const options={cached:cache,signal:new AbortController().signal,loadPreview:async()=>{reads++;return good;},
decode:async b=>{if(b===bad)throw Error('invalid pixels');return{url:'good-url'};},revoke:()=>{}};
await assert.rejects(loadPreviewPair(['a','b'],options),/invalid pixels/);
assert.equal(cache[0],null,'invalid downloaded bytes cannot poison every future retry');
await loadPreviewPair(['a','b'],options);assert.equal(reads,1,'retry fetches the corrupt half again and keeps the valid half');
console.log('Preview decoding: pre-cancel, decode timeout, dimension limits, late sibling URL cleanup and corrupt-cache recovery pass');
+74
View File
@@ -0,0 +1,74 @@
import unittest
from preview_store import PreviewStore
def key(descriptor):
return descriptor["filename"][:-4]
class PreviewStoreTests(unittest.TestCase):
def test_tiny_batches_have_a_metadata_budget_and_empty_batches_are_atomic(self):
store = PreviewStore(1024 * 1024, max_batches=2)
first = store.put_batch([b"a"], "first")
second = store.put_batch([b"b"], "second")
store.get(key(first[0]))
generation = store.fingerprint()
with self.assertRaises(ValueError):
store.put_batch([], "empty")
with self.assertRaises(ValueError):
store.put_batch([b""], "empty")
self.assertEqual(store.fingerprint(), generation)
store.put_batch([b"c"], "third")
self.assertEqual(store.get(key(first[0])), b"a")
self.assertIsNone(store.get(key(second[0])))
self.assertEqual(len(store.groups), 2)
self.assertEqual(store.size, 2)
def test_whole_batch_lru_and_cache_generation(self):
store = PreviewStore(10)
first = store.put_batch([b"aaa", b"bbb"], "1")
before = store.fingerprint()
second = store.put_batch([b"ccc", b"ddd"], "2")
self.assertNotEqual(store.fingerprint(), before)
self.assertIsNone(store.get(key(first[0])))
self.assertIsNone(store.get(key(first[1])))
self.assertEqual(store.get(key(second[0])), b"ccc")
self.assertEqual(store.get(key(second[1])), b"ddd")
self.assertNotEqual(store.fingerprint(), PreviewStore(10).fingerprint())
def test_eviction_rerun_reuses_current_batch_without_continuous_eviction(self):
store = PreviewStore(10)
store.put_batch([b"aaa", b"bbb"], "node")
current = store.put_batch([b"ccc", b"ddd"], "node")
after_eviction = store.fingerprint()
reused = store.put_batch([b"ccc", b"ddd"], "node")
self.assertEqual(reused, current)
self.assertEqual(store.fingerprint(), after_eviction)
# A true new execution with no intervening eviction has fresh identity.
regenerated = store.put_batch([b"ccc", b"ddd"], "node")
self.assertNotEqual(regenerated, current)
self.assertNotEqual(store.fingerprint(), after_eviction)
def test_oversized_batch_never_discards_prior_previews(self):
store = PreviewStore(6)
existing = store.put_batch([b"aa", b"bb"], "x")
generation = store.fingerprint()
with self.assertRaisesRegex(ValueError, "preview limit"):
store.put_batch([b"aaa", b"bbbb"], "x")
self.assertEqual(store.get(key(existing[0])), b"aa")
self.assertEqual(store.get(key(existing[1])), b"bb")
self.assertEqual(store.fingerprint(), generation)
def test_alternating_inputs_invalidates_the_old_cached_descriptors(self):
store = PreviewStore(6)
first = store.put_batch([b"aaa", b"bbb"], "node")
token = store.fingerprint()
store.put_batch([b"ccc", b"ddd"], "node")
self.assertNotEqual(token, store.fingerprint())
self.assertIsNone(store.get(key(first[0])))
restored = store.put_batch([b"aaa", b"bbb"], "node")
self.assertEqual(store.get(key(restored[0])), b"aaa")
if __name__ == "__main__":
unittest.main()
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import {readPreview} from '../web/preview_transport.js';
const bytes=new TextEncoder().encode('image-bytes');
const events=[];
const blob=await readPreview(async()=>new Response(new ReadableStream({start(c){c.enqueue(bytes.slice(0,3));c.enqueue(bytes.slice(3));c.close();}}),{headers:{'content-length':String(bytes.length),'content-type':'image/png'}}),'/preview',{onProgress:e=>events.push(e)});
assert.equal(await blob.text(),'image-bytes');assert.equal(events[1].loaded,3);assert.equal(events[1].total,bytes.length);assert.equal(events.at(-1).complete,true);
const unknown=[];await readPreview(async()=>new Response(bytes),'/preview',{onProgress:e=>unknown.push(e)});assert.equal(unknown[0].total,0);assert.equal(unknown.at(-1).loaded,bytes.length);
await assert.rejects(readPreview(async()=>new Response(bytes,{headers:{'content-length':'99'}}),'/truncated'),/interrupted/);
await assert.rejects(readPreview(async()=>new Response(bytes,{status:404}),'/expired'),e=>e.status===404);
await assert.rejects(readPreview(async()=>new Response(bytes),'/too-large',{maxBytes:3}),/size limit/);
const cancel=new AbortController();
const stalled=(_,options)=>Promise.resolve(new Response(new ReadableStream({start(c){options.signal.addEventListener('abort',()=>c.error(new DOMException('Aborted','AbortError')));}})));
await assert.rejects(readPreview(stalled,'/stalled',{idleMs:10}),/timed out/);
const pending=readPreview(stalled,'/cancel',{signal:cancel.signal});cancel.abort();await assert.rejects(pending,e=>e.name==='AbortError');
// A long transfer is allowed while actual data continues to arrive.
let ticks=0;await readPreview(async(_,options)=>new Response(new ReadableStream({start(c){const t=setInterval(()=>{if(++ticks===5){clearInterval(t);c.close();}else c.enqueue(bytes);},5);options.signal.addEventListener('abort',()=>{clearInterval(t);c.error(Error('timed out'));});}})),'/slow',{idleMs:20});assert.equal(ticks,5);
console.log('Preview transport: streamed byte progress, unknown length, truncation, expiry, size limit, inactivity timeout, cancellation and slow advancing transfer pass');
let requests=0;
await assert.rejects(readPreview(async()=>{requests++;return new Response(bytes);},'/never-start',{signal:AbortSignal.abort()}),{name:'AbortError'});
assert.equal(requests,0);
let stopped=false;
await assert.rejects(readPreview(async()=>new Response(new ReadableStream({cancel(){stopped=true;}})),'/uncooperative-wrapper',{idleMs:5}),/timed out/);
assert.equal(stopped,true,'cancel the response reader even when a custom fetch wrapper ignores its signal');
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import {ResultRecovery} from '../web/result_recovery.js';
const node={id:3,type:'AdachiImageCompare'},graph={getNodeById:id=>String(id)==='3'?node:null};
let active=graph,calls=0,fail=true,output=[],terminal=[];
const entry={status:{completed:true,status_str:'success'},outputs:{'3':{adachi_originals:[{filename:'a'}],adachi_results:[{filename:'b'}]}}};
const r=new ResultRecovery({getGraph:()=>active,interval:60000,fetchApi:async path=>{calls++;assert.equal(path,'/history/task');if(fail)throw Error('offline');return {ok:true,json:async()=>({task:entry})};},onOutput:(n,o,id)=>output.push({n,o,id}),onTerminal:(...x)=>terminal.push(x)});
try {
r.track(graph,'task','3');await r.recover();assert.equal(output.length,0);assert.equal(r.pending.size,1,'network failure retains the task even without any WebSocket events');
fail=false;await r.recover();assert.equal(output.length,1);assert.equal(output[0].n,node);assert.equal(r.pending.size,0);
r.track(graph,'task','3');await r.recover();assert.equal(output.length,1,'success cannot replay after adoption');
r.delivered.clear();r.track(graph,'task','3');active={getNodeById:()=>node};await r.recover();assert.equal(output.length,1,'another graph must not receive a result with the same node ID');
active=graph;r.track(graph,'task','3');graph.getNodeById=()=>({...node});await r.recover();assert.equal(output.length,1,'reconfigured nodes are not the queued node');graph.getNodeById=id=>String(id)==='3'?node:null;
r.track(graph,'task','3');entry.outputs={};entry.status.status_str='error';await r.recover();assert.equal(terminal.length,1);assert.equal(r.pending.size,0);
assert.ok(calls>=3);
} finally {r.destroy();}
console.log('Result recovery: lost notifications, offline retries, duplicate delivery, workflow/node replacement and terminal errors pass');
const nested={id:8,type:'AdachiImageCompare'},child={getNodeById:id=>String(id)==='8'?nested:null},container={id:5,subgraph:child},root={getNodeById:id=>String(id)==='5'?container:null};
let nestedResult;
const sub=new ResultRecovery({getGraph:()=>root,fetchApi:async()=>({ok:true,json:async()=>({nestedTask:{status:{completed:true,status_str:'success'},outputs:{'5:8':{adachi_originals:[{}],adachi_results:[{}]}}}})}),onOutput:n=>{nestedResult=n;}});
try{sub.track(root,'nestedTask','5:8');await sub.recover();assert.equal(nestedResult,nested);}finally{sub.destroy();}
console.log('Result recovery: native subgraph execution IDs resolve to the queued live comparison');
{
let resolve,signal,discarded=0,delivered=0;
const pending=new ResultRecovery({getGraph:()=>graph,onOutput:()=>delivered++,onDiscard:()=>discarded++,
fetchApi:async(_path,options)=>{signal=options.signal;return new Promise(r=>resolve=r);}});
pending.track(graph,'late','3');const task=pending.recover();pending.destroy();
assert.equal(signal.aborted,true);assert.equal(discarded,1);
resolve({ok:true,json:async()=>({late:{status:{completed:true},outputs:{'3':{adachi_originals:[{}],adachi_results:[{}]}}}})});
await task;assert.equal(delivered,0,'destroyed recovery cannot deliver a late response');
pending.track(graph,'new','3');assert.equal(pending.pending.size,0);assert.equal(pending.controllers.size,0);
}
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import {uploadImageWithProgress} from "../web/upload_transport.js";
let current, status = 200;
globalThis.XMLHttpRequest = class {
constructor() { current = this; this.upload = {}; this.headers = {}; }
open(method, url) { this.method = method; this.url = url; }
setRequestHeader(name, value) { this.headers[name] = value; }
send(body) {
this.body = body;
this.upload.onprogress({loaded: 40, total: 100, lengthComputable: true});
this.upload.onload();
this.status = status;
this.responseText = '{"name":"test.png"}';
this.onload();
}
};
const body = new FormData();
let fallback = 0, phases = [];
const api = {
user: "alice",
apiURL: path => `/cloud/proxy/api${path}`,
async fetchApi(path, options) {
fallback++;
assert.equal(path, "/upload/image"); assert.equal(options.body, body);
return {ok:true, json:async()=>({name:"fallback.png"})};
},
};
assert.deepEqual(await uploadImageWithProgress(api, body, event => phases.push(event)), {name:"test.png"});
assert.equal(current.method, "POST");
assert.equal(current.url, "/cloud/proxy/api/upload/image");
assert.equal(current.headers["Comfy-User"], "alice");
assert.deepEqual(phases, [{phase:"upload", loaded:40, total:100}, {phase:"processing"}]);
assert.equal(fallback, 0);
status = 401; phases = [];
assert.deepEqual(await uploadImageWithProgress(api, body, event => phases.push(event)), {name:"fallback.png"});
assert.equal(fallback, 1);
assert.equal(phases.at(-2).phase, "authenticated");
assert.equal(phases.at(-1).phase, "processing");
status = 500;
await assert.rejects(uploadImageWithProgress(api, body), /500/);
console.log("Upload transport: real byte progress, proxy URL, multi-user header, auth fallback and server error passed");
let aborted=0;
globalThis.XMLHttpRequest=class {
constructor(){this.upload={};}open(){}setRequestHeader(){}send(){}abort(){aborted++;this.onabort?.();}
};
await assert.rejects(uploadImageWithProgress(api,body,null,{idleMs:5}),/timed out/);
assert.equal(aborted,1);
const cancel=new AbortController(),pending=uploadImageWithProgress(api,body,null,{signal:cancel.signal});cancel.abort();
await assert.rejects(pending,{name:'AbortError'});assert.equal(aborted,2);
// Overall duration may exceed the inactivity limit as long as real bytes move.
globalThis.XMLHttpRequest=class {
constructor(){this.upload={};}open(){}setRequestHeader(){}
send(){let n=0;this.timer=setInterval(()=>{this.upload.onprogress?.({loaded:++n,total:6,lengthComputable:true});if(n===6){clearInterval(this.timer);this.status=200;this.responseText='{"name":"slow.png"}';this.onload();}},3);}
abort(){clearInterval(this.timer);this.onabort?.();}
};
assert.equal((await uploadImageWithProgress(api,body,null,{idleMs:12})).name,'slow.png');
console.log('Upload transport: inactivity timeout, cancellation and slow advancing uploads pass');
globalThis.XMLHttpRequest=class {
constructor(){this.upload={};}open(){}setRequestHeader(){}
send(){this.status=403;this.onload();}abort(){this.onabort?.();}
};
let fallbackSignal;
const stalledAuth={...api,fetchApi:async(_path,options)=>{fallbackSignal=options.signal;return new Promise((_,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true}));}};
await assert.rejects(uploadImageWithProgress(stalledAuth,body,null,{idleMs:5}),/timed out/);
assert.equal(fallbackSignal.aborted,true,'the authenticated fallback also has a cancellation deadline');
+17
View File
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import {inspectWorkbench,selectWorkbenchPipeline} from '../web/workbench_analysis.js';
const graph={_nodes:[{type:'AdachiInlineMask'},{type:'AdachiWebUIInpaint'},
{type:'AdachiImageCompare'},{type:'AdachiGroupPresets'},
{type:'CheckpointLoaderSimple'},{type:'Power Lora Loader (rgthree)'}]};
const result=inspectWorkbench(graph);
assert.deepEqual(result.missing,[]);
assert.equal(result.checkpoints.length,1);
assert.equal(result.loras.length,1);
assert.deepEqual(inspectWorkbench(null).missing,['inline_mask','inpaint','compare']);
console.log('Workbench discovery checks passed');
const first={id:10,type:'AdachiInlineMask',mode:0,inputs:[]},second={id:20,type:'AdachiInlineMask',mode:0,inputs:[]},a={id:30,type:'AdachiWebUIInpaint',mode:0,inputs:[{name:'image',link:1}]},b={id:40,type:'AdachiWebUIInpaint',mode:0,inputs:[{name:'image',link:2}]},outA={id:50,type:'AdachiImageCompare',mode:0,inputs:[{name:'result',link:3}]},outB={id:60,type:'AdachiImageCompare',mode:0,inputs:[{name:'result',link:4}]};
const multi={_nodes:[first,second,a,b,outA,outB],links:{1:{origin_id:10},2:{origin_id:20},3:{origin_id:30},4:{origin_id:40}},getNodeById(id){return this._nodes.find(n=>n.id==id);}};multi._nodes.forEach(n=>n.graph=multi);
const chosen=selectWorkbenchPipeline(multi,b);assert.equal(chosen.editor,second);assert.equal(chosen.compare,outB);assert.equal(chosen.ambiguous,false);
b.inputs=[];outB.inputs=[];assert.equal(selectWorkbenchPipeline(multi,b).ambiguous,true,'unconnected ambiguous canvases need an explicit choice');
console.log('Workbench selection: multiple independent pipelines follow actual connections; ambiguity is explicit');
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import {workspaceRoutingNodes} from '../web/workspace_activation.js';
const model={id:11,type:'CLIPTextEncode',mode:4,inputs:[]},setter={id:12,type:'SetNode',mode:4,inputs:[{link:1}]},getter={id:13,type:'GetNode',mode:4,inputs:[],findSetter:()=>setter},gate={id:14,type:'AdachiExclusiveSwitch',mode:4,inputs:[{link:2}]},root={id:15,type:'AdachiWebUIInpaint',mode:4,inputs:[{link:3}]};
const nodes=[model,setter,getter,gate,root],graph={_nodes:nodes,links:{1:{origin_id:11},2:{origin_id:13},3:{origin_id:14}},getNodeById:id=>nodes.find(n=>n.id==id)};nodes.forEach(n=>n.graph=graph);
graph._groups=[{size:[500,500],_nodes:[getter,gate,root]}];
assert.deepEqual([...workspaceRoutingNodes([root])].map(n=>n.id).sort(),[13,14]);
assert.equal(setter.mode,4);assert.equal(model.mode,4,'model processing is never activated by routing discovery');
root.mode=0;assert.equal(workspaceRoutingNodes([root]).size,0,'manually ignored routes of an enabled workbench stay ignored');
root.mode=4;gate.mode=0;assert.equal(workspaceRoutingNodes([root]).size,0,'a mixed group is not guessed to be entirely bypassed');
const controller={active:{id:'preset'},planStates:()=>({plan:new Map([[root,4],[getter,4],[gate,4]])})};
assert.deepEqual([...workspaceRoutingNodes([root],controller)].map(n=>n.id),[13],'an explicit bypass preset allows only its necessary routing');
controller.planStates=()=>({plan:new Map([[root,0]])});assert.equal(workspaceRoutingNodes([root],controller).size,0,'an individually ignored sampler cannot revive other intentionally disabled nodes');
console.log('Workspace activation: bypassed group routing only, preserved external setters and processors, explicit preset state and mixed-group safety pass');
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import {WorkspaceBindings} from '../web/workspace_bindings.js';
const nodes=[];
const add=(id,type,values={},inputs=[],outputs=[])=>{const n={id,type,mode:0,properties:{},widgets:Object.entries(values).map(([name,value])=>({name,value})),inputs,outputs:outputs.map(type=>({type,name:type}))};nodes.push(n);return n;};
const unrelated=add('old-unrelated','CheckpointLoaderSimple',{ckpt_name:'do-not-pick.safetensors'});
const a=add('base-a','CheckpointLoaderSimple',{ckpt_name:'custom-A.safetensors'});
const b=add('base-b','UNETLoader',{unet_name:'anything-B.safetensors'});
const clip=add('separate-encoder','CLIPLoader',{clip_name:'language.safetensors'});
const vae=add('separate-vae','VAELoader',{vae_name:'image.safetensors'});
const posA=add('encode-a','CLIPTextEncode',{text:'original A'},[{name:'clip',type:'CLIP'}],['CONDITIONING']);
const negA=add('negative-a','CLIPTextEncode',{text:'negative A'},[{name:'clip',type:'CLIP'}],['CONDITIONING']);
const posB=add('encode-b','CLIPTextEncode',{text:'original B'},[{name:'clip',type:'CLIP'}],['CONDITIONING']);
const negB=add('negative-b','CLIPTextEncode',{text:'negative B'},[{name:'clip',type:'CLIP'}],['CONDITIONING']);
add('model-patch','ModelSamplingSD3',{},[{name:'model',type:'MODEL'}],['MODEL']);
const sampler=add('workbench','AdachiWebUIInpaint',{refiner_switch:.8},[{name:'model',type:'MODEL'},{name:'positive',type:'CONDITIONING'},{name:'negative',type:'CONDITIONING'},{name:'vae',type:'VAE'}]);
const controller=add('preset-owner','AdachiGroupPresets');const applied=[];
controller.adachiGroupPresets={model:{node:controller,data:{activeId:'one',presets:[{id:'one',name:'Portrait'},{id:'two',name:'Studio'}]},apply(id){this.data.activeId=id;applied.push(id);}}};
const graph={_nodes:nodes,getNodeById:id=>nodes.find(n=>String(n.id)===String(id)),links:{}};
const p=(kind)=>{
const root=kind==='one'?'base-a':'base-b',positive=kind==='one'?'encode-a':'encode-b',negative=kind==='one'?'negative-a':'negative-b';
return {[root]:{class_type:graph.getNodeById(root).type,inputs:{}},'separate-encoder':{class_type:'CLIPLoader',inputs:{}},'separate-vae':{class_type:'VAELoader',inputs:{}},
'model-patch':{class_type:'ModelSamplingSD3',inputs:{model:[root,0]}},
[positive]:{class_type:'CLIPTextEncode',inputs:{clip:kind==='one'?[root,1]:['separate-encoder',0],text:'base text'}},
[negative]:{class_type:'CLIPTextEncode',inputs:{clip:kind==='one'?[root,1]:['separate-encoder',0],text:'negative text'}},
workbench:{class_type:'AdachiWebUIInpaint',inputs:{model:['model-patch',0],positive:[positive,0],negative:[negative,0],vae:kind==='one'?[root,2]:['separate-vae',0]}}};
};
const x=new WorkspaceBindings({graph,sampler});assert.equal(x.profileId,'one');assert.equal(x.model,null,'discovery cannot guess from filenames or active loader order');
x.updateExecutionGraph(p('one'),'workbench');assert.equal(x.model,a);assert.notEqual(x.model,unrelated);assert.equal(x.positive,posA);assert.equal(x.getExecutionBinding().loraMode,'inherit');
assert.deepEqual(x.getExecutionBinding().samplerInputs,{},'final model-processing references are preserved');
x.settings.positive='custom A';assert.deepEqual(x.getExecutionBinding().inputOverrides['encode-a'],{text:'custom A'});
x.settings.loraMode='override';x.settings.loras=[{name:'style.safetensors',strength:.7}];assert.equal(x.getExecutionBinding().loras[0].name,'style.safetensors');
x.selectProfile('two');x.updateExecutionGraph(p('two'),'workbench');assert.equal(x.model,b);assert.equal(x.clip,clip);assert.equal(x.vae,vae);assert.equal(x.positive,posB);assert.equal(x.getExecutionBinding().loraMode,'inherit');assert.equal(x.getExecutionBinding().inputOverrides['encode-b'],undefined);
x.selectProfile('one');x.updateExecutionGraph(p('one'),'workbench');assert.equal(x.settings.positive,'custom A');assert.deepEqual(applied,['two','one']);
controller.mode=4;const ignored=new WorkspaceBindings({graph,sampler});assert.equal(ignored.controller,undefined);assert.deepEqual(ignored.profiles.map(p=>p.id),['current']);
controller.mode=0;controller.adachiGroupPresets.model.data.presets.push({id:'new',name:'Any other title'});ignored.refreshProfiles();assert.equal(ignored.profiles.length,3);
controller.adachiGroupPresets.model.data.activeId='two';ignored.refreshProfiles();assert.equal(ignored.profileId,'two');
console.log('Workspace bindings: arbitrary IDs and titles, actual loader/CLIP/VAE routes, preserved model patches, inherited/custom LoRAs, per-profile edits and dynamic controllers pass');
+84
View File
@@ -0,0 +1,84 @@
"""Workspace validation must not execute, save or modify the submitted graph."""
import importlib.util
from pathlib import Path
import types
import unittest
SPEC = importlib.util.spec_from_file_location("adachi_workspace_execution", Path(__file__).resolve().parents[1] / "workspace_execution.py")
workspace = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(workspace)
class WorkspaceTests(unittest.IsolatedAsyncioTestCase):
def prompt(self):
return {
"1": {"class_type": "ImageSource", "inputs": {}},
"2": {"class_type": "ImageInvert", "inputs": {"image": ["1", 0]}},
"3": {"class_type": "AdachiImageCompare", "inputs": {"original": ["1", 0], "result": ["2", 0]}},
"4": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"5": {"class_type": "NotInstalled", "inputs": {}},
}
async def test_only_comparison_branch_is_validated(self):
original = self.prompt()
calls = []
async def validate(prompt_id, prompt, targets):
calls.append((prompt_id, prompt, targets))
return True, None, ["3"], {}
server = types.SimpleNamespace(trigger_on_prompt=lambda data: data)
data, _, valid = await workspace.prepare_workspace(server, types.SimpleNamespace(validate_prompt=validate), {"prompt": original, "compare_id": "3"})
self.assertTrue(valid[0])
self.assertEqual(set(data["prompt"]), {"1", "2", "3"})
self.assertEqual(calls[0][2], ["3"])
self.assertEqual(set(original), {"1", "2", "3", "4", "5"})
async def test_hooks_restore_links_before_pruning(self):
original = self.prompt()
original["2"]["inputs"].clear()
def hook(data):
data["prompt"]["2"]["inputs"]["image"] = ["1", 0]
return data
def validate(prompt):
self.assertIn("1", prompt)
return True, None, ["3"], {}
server = types.SimpleNamespace(trigger_on_prompt=hook)
await workspace.prepare_workspace(server, types.SimpleNamespace(validate_prompt=validate), {"prompt": original, "compare_id": "3"})
self.assertEqual(original["2"]["inputs"], {})
def test_missing_dependency_and_non_comparison_output_rejected(self):
with self.assertRaises(ValueError):
workspace.dependency_prompt(self.prompt(), "4")
prompt = self.prompt()
del prompt["2"]
with self.assertRaisesRegex(ValueError, "missing node"):
workspace.dependency_prompt(prompt, "3")
def test_malformed_node_inputs_are_a_validation_error(self):
for value in ([], None, "invalid", 42):
prompt = self.prompt()
prompt["2"]["inputs"] = value
with self.assertRaisesRegex(ValueError, "Invalid input mapping"):
workspace.dependency_prompt(prompt, "3")
async def test_bad_inputs_never_reach_core_replacement_hooks(self):
def unexpected(_prompt):
self.fail("Malformed inputs reached a core hook")
server = types.SimpleNamespace(trigger_on_prompt=lambda data: data,
node_replace_manager=types.SimpleNamespace(apply_replacements=unexpected))
prompt = self.prompt()
prompt["2"]["inputs"] = None
with self.assertRaisesRegex(ValueError, "Invalid input mapping"):
await workspace.prepare_workspace(server, types.SimpleNamespace(), {"prompt":prompt, "compare_id":"3"})
async def test_validation_error_is_returned_without_queueing(self):
def validate(prompt):
return False, {"message": "missing checkpoint"}, [], {"1": {"errors": []}}
server = types.SimpleNamespace(trigger_on_prompt=lambda data: data)
_, _, valid = await workspace.prepare_workspace(server, types.SimpleNamespace(validate_prompt=validate), {"prompt": self.prompt(), "compare_id": "3"})
self.assertFalse(valid[0])
self.assertEqual(valid[1]["message"], "missing checkpoint")
+206
View File
@@ -0,0 +1,206 @@
import assert from 'node:assert/strict';
import {inspectExecutionFlow, applyExecutionBinding} from '../web/workspace_flow.js';
// Execution IDs, filenames and titles deliberately have no relationship to the
// author's personal workflow. Colon IDs represent nodes inside a subgraph.
function fixture(prefix='graph-a') {
const ids=Object.fromEntries(['checkpoint','clip','vae','modelLora','negativeLora','modelPatch','clipPatch','positive','positive2','negative','combine','control','controlLoader','image','unusedCheckpoint','unusedEncoder','unusedLora','gate','sampler','compare'].map((k,i)=>[k,`${prefix}-${113+i*17}`]));
ids.positive2=`${prefix}-instance:937`;
const prompt={},live=new Map();
function add(key,type,inputs,outputs=[],fields=[]) {
const id=ids[key];prompt[id]={class_type:type,inputs};
live.set(id,{id:id.includes(':')?id.split(':').at(-1):id,type,mode:0,
outputs:outputs.map((o,i)=>typeof o==='string'?{name:o,type:o}:{...o,slot_index:i}),
inputs:Object.entries(inputs).map(([name,value])=>({name,type:Array.isArray(value)?'CONDITIONING':'STRING'})),
widgets:fields.map(name=>({name,value:inputs[name]}))});
}
add('checkpoint','CheckpointLoaderSimple',{ckpt_name:'arbitrary-custom-model.safetensors'},['MODEL','CLIP','VAE']);
add('clip','CLIPLoader',{clip_name:'independent-language-encoder.safetensors'},['CLIP']);
add('vae','VAELoader',{vae_name:'independent-image-vae.safetensors'},['VAE']);
add('modelLora','Power Lora Loader (rgthree)',{model:[ids.checkpoint,0],clip:[ids.checkpoint,1],
lora_1:{on:true,lora:'original-model-style.safetensors',strength:.8,strengthTwo:.4},
lora_9:{on:false,lora:'original-disabled.safetensors',strength:1}},['MODEL','CLIP']);
add('negativeLora','LoraLoader',{model:[ids.checkpoint,0],clip:[ids.clip,0],lora_name:'original-negative-style.safetensors',strength_model:.6,strength_clip:.3},['MODEL','CLIP']);
add('modelPatch','ModelSamplingSD3',{model:[ids.modelLora,0],shift:2.7},['MODEL']);
add('clipPatch','CLIPSetLastLayer',{clip:[ids.modelLora,1],stop_at_clip_layer:-2},['CLIP']);
add('positive','CLIPTextEncode',{clip:[ids.clipPatch,0],text:'a quiet city'},['CONDITIONING'],['text']);
add('positive2','CLIPTextEncodeSDXL',{clip:[ids.clipPatch,0],text_g:'city details',text_l:'architecture',width:1024,height:1024},['CONDITIONING'],['text_g','text_l']);
add('negative','CLIPTextEncode',{clip:[ids.negativeLora,1],text:'blurry'},['CONDITIONING'],['text']);
add('combine','ConditioningCombine',{conditioning_1:[ids.positive,0],conditioning_2:[ids.positive2,0]},['CONDITIONING']);
add('controlLoader','ControlNetLoader',{control_net_name:'edge-control.safetensors'},['CONTROL_NET']);
add('image','LoadImage',{image:'fixture.png'},['IMAGE','MASK']);
add('control','ControlNetApplyAdvanced',{positive:[ids.combine,0],negative:[ids.negative,0],control_net:[ids.controlLoader,0],image:[ids.image,0],strength:.7},
[{name:'positive',type:'CONDITIONING'},{name:'negative',type:'CONDITIONING'}]);
add('unusedCheckpoint','CheckpointLoaderSimple',{ckpt_name:'a-different-family.safetensors'},['MODEL','CLIP','VAE']);
add('unusedEncoder','CLIPTextEncode',{clip:[ids.unusedCheckpoint,1],text:'unused prompt'},['CONDITIONING'],['text']);
add('unusedLora','LoraLoader',{model:[ids.unusedCheckpoint,0],clip:[ids.unusedCheckpoint,1],lora_name:'unrelated.safetensors',strength_model:1,strength_clip:1},['MODEL','CLIP']);
add('gate','AdachiExclusiveSwitch',{active_inputs:1,input_a:[ids.control,0],input_b:[ids.unusedEncoder,0]},['CONDITIONING']);
add('sampler','AdachiWebUIInpaint',{model:[ids.modelPatch,0],positive:[ids.gate,0],negative:[ids.control,1],vae:[ids.vae,0],image:[ids.image,0],mask:[ids.image,1],steps:23,cfg:5.5},['IMAGE','IMAGE','MASK']);
add('compare','AdachiImageCompare',{original:[ids.image,0],result:[ids.sampler,0]},['IMAGE']);
for(const [id,node] of live)node.inputs=Object.entries(prompt[id].inputs).map(([name,value])=>({name,
type:Array.isArray(value)?live.get(value[0])?.outputs?.[value[1]]?.type||'*':typeof value==='number'?'FLOAT':'STRING'}));
return {ids,prompt,lookup:id=>live.get(String(id))||null,live};
}
const sorted=values=>[...values].sort();
const entryIds=entries=>sorted(entries.map(e=>String(e.id)));
const effective=(prompt,ref)=>{
const seen=new Set();
while(ref && prompt[ref[0]]?.class_type==='AdachiExclusiveSwitch' && !seen.has(ref[0])) {
seen.add(ref[0]);const n=prompt[ref[0]],flag=n.inputs.active_inputs;
if(flag!==1 && flag!==2)break;
ref=n.inputs[flag===1?'input_a':'input_b'];
}
return ref;
};
function loraChain(prompt,initial,field) {
const rows=[],seen=new Set();let ref=initial;
while(ref && prompt[ref[0]]?.class_type==='LoraLoader') {
assert.ok(!seen.has(ref[0]),'temporary LoRA chain cannot contain a cycle');seen.add(ref[0]);
assert.equal(ref[1],field==='model'?0:1,'model and CLIP must use the correct LoRA output slot');
const n=prompt[ref[0]];rows.unshift(n.inputs);ref=n.inputs[field];
}
return {rows,base:ref};
}
const inherit={loraMode:'inherit',loras:[]};
// Inspection preserves the actual producer and discovers editable components
// behind model/CLIP processing; it does not guess from a preset/model name.
for(const prefix of ['renumbered-12001','completely-different-workflow']) {
const {ids,prompt,lookup}=fixture(prefix),before=structuredClone(prompt);
const flow=inspectExecutionFlow(prompt,ids.sampler,lookup);
assert.deepEqual(flow.refs.model,[ids.modelPatch,0]);
assert.deepEqual(effective(prompt,flow.refs.positive),[ids.control,0]);
assert.deepEqual(flow.refs.negative,[ids.control,1]);
assert.deepEqual(flow.refs.vae,[ids.vae,0]);
assert.deepEqual(entryIds(flow.modelNodes),[ids.checkpoint]);
assert.deepEqual(entryIds(flow.clipNodes),sorted([ids.checkpoint,ids.clip]));
assert.deepEqual(entryIds(flow.vaeNodes),[ids.vae]);
assert.deepEqual(entryIds(flow.loras),sorted([ids.modelLora,ids.negativeLora]));
assert.deepEqual(entryIds(flow.encoders.positive),sorted([ids.positive,ids.positive2]));
assert.deepEqual(entryIds(flow.encoders.negative),[ids.negative]);
const second=flow.encoders.positive.find(e=>e.id===ids.positive2);
assert.equal(second.node,lookup(ids.positive2),'execution IDs map back to the actual live subgraph node');
assert.deepEqual(sorted(second.fields),['text_g','text_l']);
assert.equal(flow.modelNodes[0].api,prompt[ids.checkpoint]);
assert.deepEqual(prompt,before,'inspection is read-only');
}
// A checkpoint's unused CLIP/VAE outputs must not displace separately wired
// components merely because that checkpoint supplies the MODEL input.
{
const {ids,prompt,lookup}=fixture();
prompt[ids.modelLora].inputs.clip=[ids.clip,0];
const flow=inspectExecutionFlow(prompt,ids.sampler,lookup);
assert.deepEqual(entryIds(flow.clipNodes),[ids.clip]);
assert.deepEqual(entryIds(flow.vaeNodes),[ids.vae]);
}
// Inherited LoRA rows and processing nodes survive switch simplification.
{
const {ids,prompt}=fixture(),before=structuredClone(prompt);
const result=applyExecutionBinding(prompt,ids.sampler,inherit);
assert.notEqual(result,prompt);assert.notEqual(result[ids.sampler].inputs,prompt[ids.sampler].inputs);
assert.deepEqual(result[ids.sampler].inputs.positive,[ids.control,0]);
assert.deepEqual(result[ids.sampler].inputs.model,[ids.modelPatch,0]);
for(const key of ['modelLora','negativeLora','modelPatch','clipPatch','control','combine','unusedCheckpoint','unusedEncoder','unusedLora'])assert.deepEqual(result[ids[key]],before[ids[key]],key+' must keep its original fields');
assert.deepEqual(prompt,before);
}
// Explicitly disabling LoRAs bypasses both output types inside this sampler's
// dependency chain while retaining model-sampling and conditioning processors.
{
const {ids,prompt}=fixture(),before=structuredClone(prompt);
const result=applyExecutionBinding(prompt,ids.sampler,{loraMode:'override',loras:[]});
assert.deepEqual(result[ids.sampler].inputs.model,[ids.modelPatch,0]);
assert.deepEqual(result[ids.modelPatch].inputs.model,[ids.checkpoint,0]);
assert.deepEqual(result[ids.clipPatch].inputs.clip,[ids.checkpoint,1]);
assert.deepEqual(result[ids.negative].inputs.clip,[ids.clip,0]);
assert.deepEqual(result[ids.positive].inputs.clip,[ids.clipPatch,0]);
assert.deepEqual(result[ids.sampler].inputs.positive,[ids.control,0]);
assert.deepEqual(result[ids.sampler].inputs.negative,[ids.control,1]);
assert.deepEqual(result[ids.control],before[ids.control]);
assert.deepEqual(result[ids.unusedLora],before[ids.unusedLora]);
assert.deepEqual(prompt,before,'LoRA overrides only affect the returned execution copy');
}
// Added LoRAs affect MODEL and each explicitly selected CLIP boundary. Distinct
// positive/negative CLIP sources stay distinct and downstream conditions stay put.
{
const {ids,prompt}=fixture(),before=structuredClone(prompt);
const chosen=[{name:'custom-one.safetensors',strength:.7,clipStrength:0,on:true},
{name:'custom-two.safetensors',strength:1.1,clipStrength:.25,on:true},
{name:'disabled-row.safetensors',strength:9,on:false}];
const result=applyExecutionBinding(prompt,ids.sampler,{loraMode:'override',loras:chosen,
clipConsumers:[{id:ids.clipPatch,input:'clip'},{id:ids.negative,input:'clip'}]});
const model=loraChain(result,result[ids.sampler].inputs.model,'model');
assert.deepEqual(model.base,[ids.modelPatch,0]);
assert.deepEqual(model.rows.map(r=>r.lora_name),chosen.slice(0,2).map(r=>r.name));
assert.deepEqual(model.rows.map(r=>r.strength_model),[.7,1.1]);
const positive=loraChain(result,result[ids.clipPatch].inputs.clip,'clip');
const negative=loraChain(result,result[ids.negative].inputs.clip,'clip');
for(const chain of [positive,negative]) {
assert.deepEqual(chain.rows.map(r=>r.lora_name),chosen.slice(0,2).map(r=>r.name));
assert.deepEqual(chain.rows.map(r=>r.strength_clip),[0,.25],'zero CLIP strength must not fall back to model strength');
}
assert.deepEqual(positive.base,[ids.checkpoint,1]);assert.deepEqual(negative.base,[ids.clip,0]);
assert.deepEqual(result[ids.positive].inputs.clip,[ids.clipPatch,0]);
assert.deepEqual(result[ids.sampler].inputs.positive,[ids.control,0]);assert.deepEqual(result[ids.sampler].inputs.negative,[ids.control,1]);
assert.deepEqual(result[ids.control],before[ids.control]);assert.deepEqual(result[ids.unusedLora],before[ids.unusedLora]);
assert.ok(Object.keys(result).some(id=>!Object.hasOwn(prompt,id)),'custom LoRAs are execution-only nodes');
assert.deepEqual(prompt,before);
}
// Primitive edits can target an encoder inside a subgraph without replacing
// its CLIP or the downstream ControlNet/conditioning route.
{
const {ids,prompt}=fixture(),before=structuredClone(prompt);
prompt[ids.sampler].inputs.positive=[ids.control,0];
prompt[ids.sampler].inputs.refiner_model=[ids.checkpoint,0];
const result=applyExecutionBinding(prompt,ids.sampler,{...inherit,
inputOverrides:{[ids.positive2]:{text_g:'edited global prompt'}},
removeInputs:{[ids.sampler]:['refiner_model']}});
assert.equal(result[ids.positive2].inputs.text_g,'edited global prompt');
assert.equal(result[ids.positive2].inputs.text_l,before[ids.positive2].inputs.text_l);
assert.deepEqual(result[ids.positive2].inputs.clip,before[ids.positive2].inputs.clip);
assert.deepEqual(result[ids.control],before[ids.control]);
assert.deepEqual(result[ids.gate],before[ids.gate],'unreachable routers must remain untouched');
assert.equal(Object.hasOwn(result[ids.sampler].inputs,'refiner_model'),false);
assert.equal(prompt[ids.positive2].inputs.text_g,before[ids.positive2].inputs.text_g);
assert.ok(Object.hasOwn(prompt[ids.sampler].inputs,'refiner_model'));
}
// Selecting B is deterministic; unresolved/invalid selectors may be rejected
// explicitly or left intact for validation, but must never silently select A.
{
const {ids,prompt,lookup}=fixture();prompt[ids.gate].inputs.active_inputs=2;
const flow=inspectExecutionFlow(prompt,ids.sampler,lookup);
assert.deepEqual(entryIds(flow.encoders.positive),[ids.unusedEncoder]);
assert.deepEqual(applyExecutionBinding(prompt,ids.sampler,inherit)[ids.sampler].inputs.positive,[ids.unusedEncoder,0]);
for(const flags of [0,3,-1]) {
prompt[ids.gate].inputs.active_inputs=flags;
try {
const result=applyExecutionBinding(prompt,ids.sampler,inherit);
assert.deepEqual(result[ids.sampler].inputs.positive,[ids.gate,0]);
assert.deepEqual(result[ids.gate].inputs,prompt[ids.gate].inputs);
} catch(error) {
assert.notEqual(error.name,'TypeError');
assert.match(error.message,/唯一|歧义|互斥|启用|exclusive|ambiguous|enabled|branch|input/i);
}
}
}
console.log('Workspace execution flow: dynamic IDs, processing chains, split CLIP sources, multi-encoder conditioning, inherited/overridden LoRAs, switch safety and immutable unrelated branches pass');
// Real Vue-backed widget rows are proxies; ComfyUI transmits their JSON data.
{
const {ids,prompt}=fixture();prompt[ids.modelLora].inputs.lora_1=new Proxy({on:true,lora:'vue-row.safetensors',strength:.4},{});
const copy=applyExecutionBinding(prompt,ids.sampler,inherit);assert.equal(copy[ids.modelLora].inputs.lora_1.lora,'vue-row.safetensors');
}
{
const {ids,prompt,lookup}=fixture();prompt[ids.gate]={class_type:'ComfySwitchNode',inputs:{switch:false,on_true:[ids.control,0],on_false:[ids.unusedEncoder,0]}};
assert.deepEqual(entryIds(inspectExecutionFlow(prompt,ids.sampler,lookup).encoders.positive),[ids.unusedEncoder]);
assert.deepEqual(applyExecutionBinding(prompt,ids.sampler,inherit)[ids.sampler].inputs.positive,[ids.gate,0],'native switch execution is preserved');
prompt[ids.gate]={class_type:'ComfySoftSwitchNode',inputs:{switch:true,on_false:[ids.unusedEncoder,0]}};
assert.deepEqual(entryIds(inspectExecutionFlow(prompt,ids.sampler,lookup).encoders.positive),[ids.unusedEncoder]);
}
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import {WorkspaceHistory,captureSourceState,restoreSourceState} from "../web/workspace_history.js";
class Raster {
constructor(pixels) { this.pixels = pixels; this.width = 4; this.height = 3; }
toDataURL() { return JSON.stringify({pixels:this.pixels}); }
}
const decode = async data => new Raster(JSON.parse(data).pixels);
const source = name => ({src:name, width:4,height:3});
const context = prefix => ({mask:new Raster(prefix+"-mask"), baseMask:new Raster(prefix+"-base"),history:[{type:"brush",points:[{x:1,y:2}]}],future:[{type:"erase",points:[{x:2,y:1}]}],lastMask:prefix+"-serialized",dirty:false});
const widgetNames=["image","mask_data","source_data","edit_mode","crop_state","blend_data","layer_image","paint_data"];
function node() {
const n={properties:{},widgets:widgetNames.map(name=>({name,value:name})),graph:{setDirtyCanvas(){}}};
const e=n.adachiEditor={...context("repaint"),image:source("original"),ready:true,loadVersion:1,maskRevision:0,pan:{x:1,y:2},zoom:2,keepFit:true,view:"overlay",filename:{textContent:"original.png"},sizeLabel:{},embed:{checked:false},flush(){},schedule(){},updateButtons(){}};
n.adachiCrop={contexts:{blend:context("blend")},target:"mask",layer:source("layer"),cropWidth:512,cropHeight:768,revision:0,acceptVersion:0,sync(){},syncPreset(){},stopOriginalPreview(){}};
return n;
}
{
const n=node(),e=n.adachiEditor,c=n.adachiCrop;
n.adachiColorPaint={async prepareRestore(data){return data;},commitRestore(data){this.restored=data;}};
n.widgets.find(w=>w.name==="edit_mode").value="crop";
n.widgets.find(w=>w.name==="crop_state").value=JSON.stringify({locked:true,x:1,y:2,width:512,height:768,job:"job-a"});
const before=captureSourceState(n);
e.mask.pixels="edited";e.history[0].points[0].x=99;e.image=source("adopted");c.layer=source("new-layer");c.target="blend";
n.widgets.find(w=>w.name==="crop_state").value="{}";
await restoreSourceState(n,before,{decode});
assert.equal(e.image.src,"original");assert.equal(c.layer.src,"layer");
assert.equal(e.mask.pixels,"repaint-mask");assert.equal(e.baseMask.pixels,"repaint-base");
assert.equal(c.contexts.blend.mask.pixels,"blend-mask");assert.equal(c.target,"mask");
assert.equal(e.history[0].points[0].x,1);assert.equal(e.future[0].type,"erase");
assert.equal(JSON.parse(n.widgets.find(w=>w.name==="crop_state").value).job,"job-a");
assert.equal(n.adachiColorPaint.restored,'paint_data');
assert.deepEqual(e.pan,{x:1,y:2});assert.equal(e.zoom,2);assert.equal(e.keepFit,true);
assert.notEqual(e.mask,before.contexts.mask.mask);
e.mask.pixels="changed-again";await restoreSourceState(n,before,{decode});assert.equal(e.mask.pixels,"repaint-mask");
}
{
const n=node(),c=n.adachiCrop,e=n.adachiEditor;
c.contexts.mask=context("saved-repaint");Object.assign(e,context("active-blend"));c.target="blend";
const before=captureSourceState(n);e.mask.pixels="lost";await restoreSourceState(n,before,{decode});
assert.equal(e.mask.pixels,"active-blend-mask");assert.equal(c.contexts.mask.mask.pixels,"saved-repaint-mask");assert.equal(c.target,"blend");
}
{
const n=node(),before=captureSourceState(n),old=n.adachiEditor.image;
await assert.rejects(restoreSourceState(n,before,{decode:async()=>{throw Error("broken PNG")}}),/broken PNG/);
assert.equal(n.adachiEditor.image,old);
n.adachiColorPaint={async prepareRestore(){throw Error('bad paint');},commitRestore(){throw Error('must not commit');}};
await assert.rejects(restoreSourceState(n,before,{decode}),/bad paint/);
assert.equal(n.adachiEditor.image,old,'paint decode failure preserves the current image');
delete n.adachiColorPaint;
await assert.rejects(restoreSourceState(n,before,{decode:async data=>{n.adachiEditor.loadVersion++;return decode(data)}}),/image changed/);
assert.equal(n.adachiEditor.image,old);
}
{
const n=node(),before=captureSourceState(n),old=n.adachiEditor.image;
n.adachiColorPaint={revision:1,async prepareRestore(){this.revision++;return null;},commitRestore(){throw Error('must not commit');}};
await assert.rejects(restoreSourceState(n,before,{decode}),/image changed/);
assert.equal(n.adachiEditor.image,old,'paint changes during history decode must not be overwritten');
}
{
const n=node(),h=new WorkspaceHistory(n,{maxEntries:3});h.checkpoint("original");
await h.transact("adopted",async()=>{n.adachiEditor.image=source("adopted");n.widgets[0].value="adopted.png"});
assert.equal(h.entries.length,2);assert.equal(h.current.label,"adopted");assert.equal(h.entries[0].state.image.src,"original");
const count=h.entries.length;await assert.rejects(h.transact("failed",async()=>{throw Error("upload failed")}),/upload failed/);assert.equal(h.entries.length,count);assert.equal(h.busy,false);
for(let i=0;i<6;i++){n.adachiEditor.image=source("next"+i);n.widgets[0].value="next"+i;h.checkpoint("next"+i);}
assert.equal(h.entries.length,3);assert.equal(h.entries[0].label,"original");assert.equal(h.current.label,"next5");
n.adachiEditor.stroke={};assert.throws(()=>h.checkpoint("stroke"),/Finish the current edit/);
}
console.log("Workspace source history: repaint/blend pixels, crop state, layer, commands, failure atomicity and retention passed");
{
const n=node(),before=captureSourceState(n),created=[];
let count=0;
await assert.rejects(restoreSourceState(n,before,{decode:async data=>{if(++count===2)throw Error('bad base');const c=await decode(data);created.push(c);return c;}}),/bad base/);
assert.equal(created[0].width,0,'a partial decode failure frees already allocated masks');
assert.equal(n.adachiEditor.image,before.image);
for(const change of [()=>n.adachiEditor.maskRevision++,()=>n.adachiCrop.revision++]){
await assert.rejects(restoreSourceState(n,before,{decode:async data=>{change();return decode(data);}}),/image changed/);
assert.equal(n.adachiEditor.image,before.image,'a completed stroke or crop change during decoding is preserved');
}
await assert.rejects(restoreSourceState(n,before,{decode:async data=>{n.graph=null;return decode(data);}}),/image changed/);
}
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import {WorkspaceBindings} from '../web/workspace_bindings.js';
const n=(id,type,values={})=>({id,type,mode:0,properties:{},widgets:Object.entries(values).map(([name,value])=>({name,value})),inputs:[]});
const sampler=n(501,'AdachiWebUIInpaint');
const nodes=[sampler,n(359,'CheckpointLoaderSimple',{ckpt_name:'anima_baseV10.safetensors'}),n(357,'CLIPLoader',{clip_name:'qwen_3_06b_base.safetensors'}),n(358,'VAELoader',{vae_name:'qwen_image_vae.safetensors'}),n(547,'CLIPTextEncode',{text:''}),n(16,'CLIPTextEncode',{text:''}),n(518,'CheckpointLoaderSimple',{ckpt_name:'wai.safetensors'}),n(521,'CLIPTextEncode',{text:''}),n(520,'CLIPTextEncode',{text:''})];
const controller=n(524,'AdachiGroupPresets');nodes.push(controller);
controller.adachiGroupPresets={model:{node:controller,data:{activeId:'anima',presets:[{id:'anima',name:'anima修图'},{id:'wai',name:'wai生图'}]},apply(id){this.data.activeId=id;}}};
const graph={_nodes:nodes,links:{},getNodeById:id=>nodes.find(n=>String(n.id)===String(id))};
const compiled=(profile)=>profile==='anima'?{
'359':{class_type:'CheckpointLoaderSimple',inputs:{}},'357':{class_type:'CLIPLoader',inputs:{}},'358':{class_type:'VAELoader',inputs:{}},
'547':{class_type:'CLIPTextEncode',inputs:{clip:['357',0],text:''}},'16':{class_type:'CLIPTextEncode',inputs:{clip:['357',0],text:''}},
'501':{class_type:'AdachiWebUIInpaint',inputs:{model:['359',0],positive:['547',0],negative:['16',0],vae:['358',0]}}
}:{'518':{class_type:'CheckpointLoaderSimple',inputs:{}},'521':{class_type:'CLIPTextEncode',inputs:{clip:['518',1],text:''}},'520':{class_type:'CLIPTextEncode',inputs:{clip:['518',1],text:''}},'501':{class_type:'AdachiWebUIInpaint',inputs:{model:['518',0],positive:['521',0],negative:['520',0],vae:['518',2]}}};
const b=new WorkspaceBindings({graph,sampler});b.updateExecutionGraph(compiled('anima'),'501');assert.equal(b.missingDependencies().length,0,'not-yet-loaded lists cannot prove missing weights');
const info=(type,name,values)=>({[type]:{input:{required:{[name]:[values]}}}});
const sources={'/object_info/CheckpointLoaderSimple':info('CheckpointLoaderSimple','ckpt_name',['wai.safetensors']),'/object_info/UNETLoader':info('UNETLoader','unet_name',[]),'/object_info/CLIPLoader':info('CLIPLoader','clip_name',[]),'/object_info/VAELoader':info('VAELoader','vae_name',['pixel_space']),'/models/loras':[]};
await b.loadCatalog(async path=>({ok:true,json:async()=>sources[path]}));
assert.deepEqual(b.missingDependencies().map(d=>d.widget.value),['anima_baseV10.safetensors','qwen_3_06b_base.safetensors','qwen_image_vae.safetensors']);
assert.deepEqual(b.missingDependencies().map(d=>d.folder),['checkpoints','text_encoders','vae']);
b.selectProfile('wai');b.updateExecutionGraph(compiled('wai'),'501');assert.equal(b.missingDependencies().length,0,'an available checkpoint supplies its own CLIP and VAE');assert.equal(b.modelDependencies().length,1);
b.selectProfile('anima');b.updateExecutionGraph(compiled('anima'),'501');b.catalog.checkpoints.push('anima_baseV10.safetensors');b.catalog.clips.push('qwen_3_06b_base.safetensors');b.catalog.vaes.push('qwen_image_vae.safetensors');assert.equal(b.missingDependencies().length,0);
const failed=new WorkspaceBindings({graph,sampler});failed.updateExecutionGraph(compiled('anima'),'501');await failed.loadCatalog(async()=>{throw Error('network offline');});assert.equal(failed.missingDependencies().length,0,'a network failure must not be reported as missing models');assert.ok(failed.catalogError);
console.log('Workspace model dependencies: separate model/CLIP/VAE, absent weights, built-in checkpoint components and unavailable catalogs pass');
+22
View File
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import {WorkspaceProgress} from '../web/workspace_progress.js';
const nodes={'1':{class_type:'CheckpointLoaderSimple'},'2':{class_type:'AdachiWebUIInpaint'},'3':{class_type:'AdachiImageCompare'}};
const p=new WorkspaceProgress();p.begin(nodes);
p.handle('executing',null);
p.handle('execution_start',{prompt_id:'mine'});p.handle('progress',{prompt_id:'other',node:'2',value:9,max:10});
p.attach('mine');assert.equal(p.state,'running');assert.equal(p.value,null,'a different prompt must not set the percentage');
p.handle('executing',1);assert.equal(p.label,'Loading main model','legacy scalar node IDs remain supported');
p.handle('executing',{prompt_id:'mine',node:'1'});assert.equal(p.label,'Loading main model');assert.equal(p.max,null);
p.handle('progress',{prompt_id:'mine',node:'2',value:3,max:20});assert.equal(p.value,3);assert.equal(p.max,20);assert.match(p.label,/KSampler/);
p.handle('executing',{prompt_id:'mine',node:null});assert.equal(p.value,3,'terminal executing packets cannot reset the sampler progress');
assert.equal(p.handle('progress',{prompt_id:'other',node:'2',value:19,max:20}),false);assert.equal(p.value,3);
p.handle('progress_state',{prompt_id:'mine',nodes:{'1':{state:'finished',value:1,max:1},'2':{state:'running',value:4,max:20}}});assert.equal(p.value,4);
p.handle('executing',{prompt_id:'mine',node:'3'});assert.equal(p.value,null,'a new node cannot inherit the sampler percentage');assert.equal(p.label,'Preparing comparison images');
p.connection(false);assert.equal(p.max,null);assert.match(p.label,/Connection lost/);
p.handle('execution_success',{prompt_id:'mine'});assert.equal(p.value/p.max,1);assert.equal(p.state,'complete');
p.handle('progress',{prompt_id:'mine',node:'2',value:1,max:20});assert.equal(p.state,'complete','late packets must not regress completion');
p.begin(nodes,'error');p.handle('execution_error',{prompt_id:'error',exception_message:'missing model'});assert.equal(p.state,'error');assert.equal(p.label,'missing model');assert.equal(p.value,null);
p.begin(nodes,'cache');p.handle('execution_cached',{prompt_id:'cache',nodes:['1','2']});assert.match(p.label,/2 nodes/);assert.equal(p.value,null);
p.handle('progress_state',{prompt_id:'cache',nodes:{'2':{state:'running',value:0,max:1}}});assert.equal(p.max,null,'node-start placeholder 0/1 must remain indeterminate');
p.begin(nodes);p.handle('execution_success',{prompt_id:'fast'});p.handle('executing',null);p.attach('fast');assert.equal(p.state,'complete','legacy completion before HTTP acknowledgment must not throw');
console.log('Workspace progress: actual stage/steps, task filtering, early events, both progress protocols, cache, reconnect and late-packet safety pass');
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import {WorkspaceBindings} from '../web/workspace_bindings.js';
class Element {
constructor(tag,doc){this.tagName=tag.toUpperCase();this.ownerDocument=doc;this.children=[];this.dataset={};this.isConnected=true;this.attributes={};}
append(...nodes){for(const node of nodes)node.parentElement=this;this.children.push(...nodes);}
replaceChildren(...nodes){this.children.forEach(child=>{child.isConnected=false;});this.children=[...nodes];}
setAttribute(name,value){this.attributes[name]=value;}
remove(){this.isConnected=false;}
contains(node){return this===node||this.children.some(child=>child.contains?.(node));}
querySelectorAll(tag){return this.children.flatMap(child=>[...(child.tagName===tag.toUpperCase()?[child]:[]),...child.querySelectorAll(tag)]);}
closest(){let node=this;while(node){if(node.tagName==='DETAILS'&&!node.open)return node;node=node.parentElement;}return null;}
focus(){this.ownerDocument.activeElement=this;}
setSelectionRange(start,end,direction){this.selectionStart=start;this.selectionEnd=end;this.selectionDirection=direction;}
}
const doc={createElement:tag=>new Element(tag,doc)};globalThis.document=doc;
const sampler={id:1,type:'AdachiWebUIInpaint',mode:0,properties:{},inputs:[],widgets:[]};
const controller={id:2,type:'AdachiGroupPresets',mode:0,properties:{}};
controller.adachiGroupPresets={model:{node:controller,data:{activeId:'one',presets:[{id:'one',name:'One'},{id:'two',name:'Two'}]},apply(id){this.data.activeId=id;}}};
const graph={_nodes:[sampler,controller],getNodeById:id=>graph._nodes.find(n=>String(n.id)===String(id)),links:{}};
let changed=0,checks=0;
const binding=new WorkspaceBindings({graph,sampler,markChanged:()=>changed++,onChange:()=>checks++});
const entry=(id,text)=>({id,node:{type:'CLIPTextEncode',widgets:[{name:'text',value:text}]},api:{class_type:'CLIPTextEncode',inputs:{text}},fields:['text']});
const positive=entry('p','positive original'),negative=entry('n','negative original');
const flow=(positives=[positive])=>({encoders:{positive:positives,negative:[negative]},modelNodes:[],clipNodes:[],vaeNodes:[],loras:[],refs:{},clipConsumers:[]});
binding.flow=flow();binding.samplerId='1';binding.createPanel();
const section=kind=>binding.root.children.find(x=>x.dataset.promptKind===kind);
const textarea=group=>group.children.flatMap(x=>x.children||[]).find(x=>x.tagName==='TEXTAREA');
assert.equal(section('positive').open,true);assert.equal(section('negative').open,true);
let positivePanel=section('positive');positivePanel.ontoggle();
assert.equal(changed,0,'initial native toggle does not dirty the workflow');
positivePanel.open=false;positivePanel.ontoggle();
assert.equal(changed,1);assert.equal(checks,0,'folding is cosmetic and does not trigger workflow validation');
assert.equal(sampler.properties.adachi_workspace_ui.promptOpen.positive,false);
assert.equal(section('negative').open,true,'fold states are independent');
const n=textarea(section('negative'));n.value='edited negative';n.oninput();n.setSelectionRange(3,7,'forward');n.scrollTop=12;n.focus();
assert.deepEqual(binding.getExecutionBinding().inputOverrides.n,{text:'edited negative'});
binding.render();assert.equal(section('positive').open,false);assert.equal(section('negative').open,true);
assert.equal(doc.activeElement,textarea(section('negative')),'asynchronous panel rendering retains the focused prompt');
assert.equal(doc.activeElement.selectionStart,3);assert.equal(doc.activeElement.selectionEnd,7);assert.equal(doc.activeElement.scrollTop,12);
positivePanel.open=true;positivePanel.ontoggle();
assert.equal(sampler.properties.adachi_workspace_ui.promptOpen.positive,false,'events from detached panels cannot override current state');
binding.selectProfile('two');assert.equal(section('positive').open,false);
section('negative').open=false;section('negative').ontoggle();
binding.selectProfile('one');binding.flow=flow();binding.samplerId='1';binding.render();
assert.equal(section('positive').open,false);assert.equal(section('negative').open,false);
assert.deepEqual(binding.getExecutionBinding().inputOverrides.n,{text:'edited negative'},'profile changes preserve prompt overrides');
const restoredSampler={...sampler,properties:JSON.parse(JSON.stringify(sampler.properties))};
const restoredGraph={_nodes:[restoredSampler],getNodeById:()=>restoredSampler,links:{}};
const restored=new WorkspaceBindings({graph:restoredGraph,sampler:restoredSampler});restored.flow=flow();restored.createPanel();
assert.equal(restored.root.children.find(x=>x.dataset.promptKind==='positive').open,false);
assert.equal(restored.root.children.find(x=>x.dataset.promptKind==='negative').open,false,'saved workflow retains both folds');
delete restoredSampler.properties.adachi_workspace_ui;
restored.flow=flow([positive,entry('p2','other')]);restored.render();
const multi=restored.root.children.find(x=>x.dataset.promptKind==='positive');
assert.equal(multi.open,false,'existing multi-encoder panels default to collapsed');
assert.equal(multi.children.filter(x=>x.tagName==='LABEL').length,2,'all encoder fields remain available');
console.log('Workspace prompt folding: independent states, cosmetic toggles, profile/workflow persistence, stale events and multi-encoder prompts pass');
+10
View File
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';import {conditioningSource} from '../web/workspace_routes.js';
const nodes=[];const add=(id,type,inputs=[],value)=>{const n={id,type,mode:0,inputs,widgets:[{value}]};nodes.push(n);return n};
const a=add(1,'CLIPTextEncode'),b=add(2,'CLIPTextEncode');const sa=add(3,'SetNode',[{name:'x',link:1}],'p');const ga=add(4,'GetNode',[],'p');const sb=add(5,'SetNode',[{name:'x',link:2}],'p2');const gb=add(6,'GetNode',[],'p2');const sw=add(7,'AdachiExclusiveSwitch',[{name:'input_a',link:3},{name:'input_b',link:4}]);const sampler=add(8,'AdachiWebUIInpaint',[{name:'positive',link:5},{name:'negative',link:6}]);
const links={1:{origin_id:1,origin_slot:0},2:{origin_id:2,origin_slot:0},3:{origin_id:4,origin_slot:0},4:{origin_id:6,origin_slot:0},5:{origin_id:7,origin_slot:0},6:{origin_id:2,origin_slot:0}};
const g={_nodes:nodes,links,getNodeById:id=>nodes.find(n=>n.id==id)};nodes.forEach(n=>n.graph=g);
assert.equal(conditioningSource(g,sampler,'positive'),null,'ambiguous branches must not be guessed');
assert.equal(conditioningSource(g,sampler,'positive',new Map([[sb,4]])),a);
assert.equal(conditioningSource(g,sampler,'negative'),b);
sa.inputs[0].link=3;assert.equal(conditioningSource(g,sampler,'positive',new Map([[sb,4]])),null,'cycle must stop');
console.log('Conditioning discovery: actual sampler route, Get/Set, preset state, ambiguity and cycles pass');
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import {workspaceRequest} from '../web/workspace_transport.js';
let posts=0;const reply=(status,data)=>({status,ok:status<300,json:async()=>data});
await assert.rejects(workspaceRequest({fetchApi:async(p,o)=>{if(o.method)posts++;return reply(405,{});}},'submit',{}),/backend is not loaded/);assert.equal(posts,0);
const seen=[];
const api={fetchApi:async(p,o)=>{seen.push(p);if(p==='/adachi-inpaint/workspace/capabilities')return reply(404,{});if(p.endsWith('/capabilities'))return reply(200,{api_version:1,can_validate:true});return reply(200,{valid:true});}};
assert.equal((await workspaceRequest(api,'validate',{})).valid,true);assert.equal(seen.at(-1),'/api/adachi-inpaint/workspace/validate');
await assert.rejects(workspaceRequest({fetchApi:async(p)=>p.endsWith('/capabilities')?reply(200,{api_version:1,can_validate:true}):reply(400,{valid:false,error:{message:'bad model'}})},'submit',{}),/bad model/);
console.log('Workspace transport: missing backend blocks submission, /api proxy fallback and validation errors pass');
const missing={fetchApi:async p=>p.endsWith('/capabilities')?reply(200,{api_version:1,can_validate:true}):reply(200,{valid:false,node_errors:{'357':{errors:[{type:'value_not_in_list',message:'Value not in list',details:'long technical inventory',extra_info:{input_name:'clip_name',received_value:'qwen_3_06b_base.safetensors'}}]}}})};
await assert.rejects(workspaceRequest(missing,'validate',{}),e=>e.message==='Text encoder not found: qwen_3_06b_base.safetensors' && !!e.nodeErrors['357']);
globalThis.comfyAPI={app:{app:{ui:{settings:{getSettingValue:()=> 'zh'}}}}};
try {
await assert.rejects(workspaceRequest(missing,'validate',{}),e=>e.message==='未找到文本编码器:qwen_3_06b_base.safetensors' && !!e.nodeErrors['357']);
} finally {delete globalThis.comfyAPI;}
+40
View File
@@ -0,0 +1,40 @@
import {T} from './i18n.js';
// A separate transparent image-space layer. It never mutates source Image
// objects held by the workspace's history and is persisted in paint_data.
export class ColorPaint {
constructor(editor,crop,widget,changed,imageUrl=x=>x){
Object.assign(this,{editor,crop,widget,changed,imageUrl});this.active=false;this.color='#ff8a32';this.undo=[];this.redo=[];this.revision=0;
this.layer=document.createElement('canvas');
const controls=document.createElement('div');controls.className='dd-color-paint';
this.button=document.createElement('button');this.button.type='button';this.button.textContent='✎';this.button.title=T('Paint image pixels; Alt-click picks a color; Ctrl+wheel changes brush size');this.button.setAttribute('aria-label',this.button.title);
this.button.onclick=e=>{e.preventDefault();e.stopPropagation();if(!this.widget){editor.status(T('The paint layer is unavailable; update and restart ComfyUI'),true);return;}this.active=!this.active;if(this.active && editor.view==='mask')editor.view='overlay';this.sync();editor.schedule();};
this.picker=document.createElement('input');this.picker.type='color';this.picker.value=this.color;this.picker.title=T('Brush color');this.picker.setAttribute('aria-label',this.picker.title);this.picker.oninput=()=>{this.color=this.picker.value;};
this.undoButton=document.createElement('button');this.undoButton.type='button';this.undoButton.textContent='↶';this.undoButton.title=T('Undo color painting');this.undoButton.onclick=()=>this.step(this.undo,this.redo);
this.redoButton=document.createElement('button');this.redoButton.type='button';this.redoButton.textContent='↷';this.redoButton.title=T('Redo color painting');this.redoButton.onclick=()=>this.step(this.redo,this.undo);
controls.append(this.button,this.picker,this.undoButton,this.redoButton);crop.bar.prepend(controls);this.controls=controls;
const draw=editor.options.drawSource,revision=editor.options.sceneRevision;
editor.options.drawSource=ctx=>{const done=draw?.(ctx);if(!done)ctx.drawImage(editor.image,0,0);if(this.layer.width&&!crop.previewOriginal)ctx.drawImage(this.layer,0,0);return true;};
editor.options.sceneRevision=()=>`${revision?.()||''}:paint:${this.revision}`;
const down=editor.pointerDown.bind(editor),move=editor.pointerMove.bind(editor),up=editor.pointerUp.bind(editor);
editor.pointerDown=e=>{if(this.active && this.restoring){e.preventDefault();e.stopPropagation();return;}if(!this.active||e.button!==0||editor.space||!editor.ready||crop.busy||crop.previewOriginal||(crop.mode==='crop'&&!crop.region?.locked))return down(e);
e.preventDefault();e.stopPropagation();const p=editor.point(e);if(e.altKey){this.pick(p);return;}
this.ensure();this.remember();this.redo=[];this.drawing={id:e.pointerId,last:p};editor.pointerId=e.pointerId;editor.canvas.setPointerCapture(e.pointerId);this.segment(p,p);this.sync();};
editor.pointerMove=e=>{if(!this.drawing||this.drawing.id!==e.pointerId)return move(e);e.preventDefault();e.stopPropagation();const events=e.getCoalescedEvents?.()||[];for(const event of events.length?events:[e]){const p=editor.point(event);this.segment(this.drawing.last,p);this.drawing.last=p;}editor.cursor=editor.point(e);editor.schedule();};
editor.pointerUp=e=>{if(!this.drawing||this.drawing.id!==e.pointerId)return up(e);e.preventDefault();e.stopPropagation();this.drawing=null;editor.pointerId=null;if(editor.canvas.hasPointerCapture(e.pointerId))editor.canvas.releasePointerCapture(e.pointerId);this.flush();this.sync();};
this.sync();
}
ensure(){const {image}=this.editor;if(!image)return;if(this.layer.width!==image.width||this.layer.height!==image.height){this.layer.width=image.width;this.layer.height=image.height;}}
segment(a,b){const ctx=this.layer.getContext('2d');ctx.save();const bounds=this.editor.options.paintBounds?.();if(bounds){ctx.beginPath();ctx.rect(bounds.x,bounds.y,bounds.width,bounds.height);ctx.clip();}ctx.strokeStyle=this.color;ctx.fillStyle=this.color;ctx.lineWidth=this.editor.brushSize;ctx.lineCap='round';ctx.lineJoin='round';ctx.globalAlpha=this.editor.brushOpacity/100;if(a.x===b.x&&a.y===b.y){ctx.beginPath();ctx.arc(a.x,a.y,ctx.lineWidth/2,0,Math.PI*2);ctx.fill();}else{ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke();}ctx.restore();this.revision++;this.editor.schedule();}
pick(p){const c=document.createElement('canvas');c.width=c.height=1;const x=c.getContext('2d',{willReadFrequently:true});x.translate(-Math.floor(p.x),-Math.floor(p.y));this.editor.options.drawSource(x);const [r,g,b,a]=x.getImageData(0,0,1,1).data;if(a){this.color='#'+[r,g,b].map(n=>n.toString(16).padStart(2,'0')).join('');this.picker.value=this.color;}}
flush(){if(!this.widget||!this.layer.width)return;try{this.widget.value=this.layer.toDataURL('image/png');this.changed();}catch(error){this.editor.status(error.message,true);throw error;}}
async prepareRestore(data,width,height){if(!data)return null;const image=new Image();image.src=data.startsWith('data:')?data:this.imageUrl(data);await image.decode();if(image.width!==width||image.height!==height)throw Error(T('Paint layer dimensions do not match the source image'));return image;}
commitRestore(image,width,height){this.undo=[];this.redo=[];this.layer.width=width;this.layer.height=height;if(image)this.layer.getContext('2d').drawImage(image,0,0);this.revision++;this.editor.schedule();this.sync();}
async restore(data){const width=this.editor.image.width,height=this.editor.image.height,loadVersion=this.editor.loadVersion,revision=this.revision,version=this.restoreVersion=(this.restoreVersion||0)+1;this.restoring=true;try{const image=await this.prepareRestore(data,width,height);if(this.editor.destroyed||this.restoreVersion!==version||this.editor.loadVersion!==loadVersion||this.revision!==revision||this.widget?.value!==data)return false;this.commitRestore(image,width,height);return true;}finally{if(this.restoreVersion===version)this.restoring=false;}}
remember(){this.undo.push(this.layer.toDataURL('image/png'));let bytes=this.undo.reduce((n,s)=>n+s.length*2,0);while(this.undo.length>1&&(this.undo.length>20||bytes>64*1024*1024))bytes-=this.undo.shift().length*2;}
async step(from,to){if(this.drawing||this.restoring||!from.length)return;this.restoring=true;const current=this.layer.toDataURL('image/png'),data=from.pop(),image=new Image();image.src=data;try{await image.decode();to.push(current);const ctx=this.layer.getContext('2d');ctx.clearRect(0,0,this.layer.width,this.layer.height);ctx.drawImage(image,0,0);this.revision++;this.flush();this.editor.schedule();this.sync();}catch(e){from.push(data);this.editor.status(e.message,true);}finally{this.restoring=false;}}
clear(){this.undo=[];this.redo=[];this.layer.width=this.layer.height=0;if(this.widget)this.widget.value='';this.revision++;this.sync();}
destroy(){this.layer.width=this.layer.height=0;this.controls.remove();}
clearRegion(r){if(!this.layer.width)return;this.remember();this.redo=[];this.layer.getContext('2d').clearRect(r.x,r.y,r.width,r.height);this.revision++;this.flush();this.editor.schedule();this.sync();}
sync(){this.editor.stage.classList.toggle('dd-color-active',this.active);this.button.classList.toggle('dd-active',this.active);this.button.setAttribute('aria-pressed',String(this.active));this.picker.hidden=this.undoButton.hidden=this.redoButton.hidden=!this.active;this.undoButton.disabled=!this.undo.length;this.redoButton.disabled=!this.redo.length;this.editor.updateButtons?.();}
}
+197 -93
View File
@@ -1,91 +1,96 @@
/* Original hover-comparison UI, MIT licensed. */
/* SPDX-License-Identifier: GPL-3.0-only
* Original hover-comparison UI. */
import { T } from "./i18n.js";
import {loadPreviewPair} from './preview_images.js';
import {CompareViewport} from './compare_viewport.js';
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
};
const imageFrom = url => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("预览已过期或无法加载,请重新运行 / Preview unavailable; run the workflow again"));
image.src = url;
});
export class ImageCompareView {
constructor(options) {
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;
const head = element("div", "dd-head");
head.append(element("span", "dd-brand", "ADACHI / COMPARE"));
this.dimensionsLabel = element("span", "dd-dimensions", "原图 / 重绘结果"); head.append(this.dimensionsLabel);
this.viewport = new CompareViewport();this.workspaceZoom=false;this.displayMode='compare';
this.dimensionsLabel = element("span", "dd-dimensions");
const toolbar = element("div", "dd-row dd-compare-toolbar");
toolbar.append(element("span", "dd-compare-label", "结果"));
this.resultSelect = element("select"); this.resultSelect.setAttribute("aria-label", "选择重绘结果 / Choose result");
toolbar.append(element("span", "dd-compare-label", T("Result")));
this.resultSelect = element("select"); this.resultSelect.setAttribute("aria-label", T("Choose result"));
this.resultSelect.onchange = () => { this.index = Number(this.resultSelect.value); this.options.onSelect?.(this.index); this.loadSelected(); };
toolbar.append(this.resultSelect);
toolbar.append(this.resultSelect, this.dimensionsLabel);
const spacer = element("span", "dd-spacer"); toolbar.append(spacer);
const fit = element("button", "", "适应"); fit.type = "button";
fit.onclick = () => { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }; toolbar.append(fit);
this.reloadButton = element("button", "", "↻"); this.reloadButton.type = "button";
this.reloadButton.title = T("Reload comparison images"); this.reloadButton.setAttribute("aria-label", this.reloadButton.title);
this.reloadButton.onclick = async event => { event.preventDefault(); event.stopPropagation(); await this.options.recover?.(); this.loadSelected(); };
toolbar.append(this.reloadButton);
this.downloadButton = element("button", "", "↓"); this.downloadButton.type = "button";
this.downloadButton.title = T("Download the latest generated image");
this.downloadButton.setAttribute("aria-label", this.downloadButton.title);
this.downloadButton.onclick = event => { event.preventDefault(); event.stopPropagation(); this.downloadLatest(); };
toolbar.append(this.downloadButton);
const fit = element("button", "dd-compare-fit", T("Fit")); fit.type = "button";
fit.onclick = event => { event.preventDefault(); event.stopPropagation(); this.fitView(); }; 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", "左原图、右重绘结果,移动鼠标竖线对比 / Original left, result right; hover to compare");
this.canvas.setAttribute("aria-label", T("Result left, original right; hover to compare"));
this.canvas.oncontextmenu = event => event.preventDefault();
this.stage.append(this.canvas);
const captions = element("div", "dd-compare-captions");
captions.append(element("span", "", "原图"), element("span", "", "重绘结果")); this.stage.append(captions);
this.empty = element("div", "dd-empty", "连接原图与重绘结果,然后运行工作流"); this.stage.append(this.empty);
this.canvas.addEventListener("pointerenter", event => { this.hovered = true; this.move(event); this.schedule(); });
this.canvas.addEventListener("pointerleave", () => { this.hovered = false; this.schedule(); });
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.progressBox=element("div","dd-preview-progress");this.progressBox.hidden=true;
this.progressBox.setAttribute("role","status");
this.progressText=element("span");this.progressBar=document.createElement("progress");this.progressBar.max=1;
this.progressBar.setAttribute("aria-label",T("Comparison image loading progress"));
this.progressBox.append(this.progressText,this.progressBar);this.stage.append(this.progressBox);
this.retryOnOnline=()=>{if(!this.ready && !this.loadingActive && this.results.length)this.loadSelected();};
window.addEventListener("online",this.retryOnOnline);
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", "回填到"));
this.targetSelect = element("select"); this.targetSelect.setAttribute("aria-label", "接收结果的内联蒙版 / Target inline editor");
adoption.append(element("span", "dd-compare-label", T("Adopt into")));
this.targetSelect = element("select"); this.targetSelect.setAttribute("aria-label", T("Target inline editor"));
this.targetSelect.onfocus = () => this.refreshTargets();
this.targetSelect.onchange = () => { this.manualTarget = true; this.updateButton(); };
adoption.append(this.targetSelect);
this.adoptButton = element("button", "dd-primary", "采用此结果"); this.adoptButton.type = "button";
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", "鼠标移动竖线对比 · 滚轮缩放 · 中键拖动");
this.root.append(head, toolbar, this.stage, adoption, this.message);
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.workspaceZoom && this.ready && event.button===1){event.preventDefault();event.stopPropagation();this.panDrag={id:event.pointerId,x:event.clientX,y:event.clientY};this.canvas.setPointerCapture(event.pointerId);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);
for(const type of ['pointerup','pointercancel','lostpointercapture'])this.canvas.addEventListener(type,event=>{if(this.panDrag?.id===event.pointerId)this.panDrag=null;});
this.canvas.addEventListener("wheel", event => this.wheel(event), { passive: false });
this.captureWheel = event => { if (event.target === this.canvas) this.wheel(event); };
window.addEventListener("wheel", this.captureWheel, { capture: true, 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();
}
status(text, error = false) { this.message.textContent = text; this.message.classList.toggle("dd-error", error); }
status(text, error = false) { this.message.textContent = text; this.message.classList.toggle("dd-error", error); this.message.hidden = !error; }
refreshTargets() {
const selected = this.targetSelect.value;
const { editors = [], preferred = "" } = this.options.getTargets?.() || {};
this.targetSelect.replaceChildren();
const placeholder = element("option", "", "选择内联蒙版"); placeholder.value = ""; this.targetSelect.append(placeholder);
const placeholder = element("option", "", T("Select an inline mask")); placeholder.value = ""; this.targetSelect.append(placeholder);
for (const editor of editors) {
const option = element("option", "", editor.label); option.value = String(editor.id); this.targetSelect.append(option);
}
@@ -94,7 +99,30 @@ export class ImageCompareView {
this.updateButton();
}
updateButton() { this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy; }
updateButton() {
this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy || this.pendingOutputs>0;
this.adoptButton.textContent = this.busy ? T("Adopting…") : T("Adopt this result");
this.downloadButton.disabled = !this.latestResult || !!this.downloading;
}
setLatestResult(result) { this.latestResult = result || null; this.updateButton(); }
async downloadLatest() {
if (!this.latestResult || this.downloading) return;
const result = this.latestResult;
this.downloading = true; this.updateButton();
const controller=this.downloadController=new AbortController();
try {
const blob = await this.options.fetchResult(result,{signal:controller.signal});
if (this.destroyed) return;
const url = URL.createObjectURL(blob), link = document.createElement("a");
link.href = url; link.download = "adachi-inpaint-result.png";
document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
this.status("");
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally {if(this.downloadController===controller)this.downloadController=null;this.downloading = false; if (!this.destroyed) this.updateButton();}
}
async setData(data, index = this.index) {
this.originals = Array.isArray(data?.originals) ? data.originals : [];
@@ -110,89 +138,165 @@ export class ImageCompareView {
await this.loadSelected();
}
async loadSelected() {
const version = ++this.version;
this.ready = false; this.updateButton();
if (!this.originals.length || !this.results.length) return;
const repetitions = this.results.length / this.originals.length;
const original = this.originals[Math.min(this.originals.length - 1, Math.floor(this.index / repetitions))];
loading(text, states) {
this.progressBox.hidden=false;this.progressText.textContent=text;
if(states) {
const loaded=states.reduce((n,s)=>n+s.loaded,0),total=states.every(s=>s.total>0)?states.reduce((n,s)=>n+s.total,0):0;
const size=n=>n>=1048576?`${(n/1048576).toFixed(1)} MiB`:`${Math.round(n/1024)} KiB`;
this.progressText.textContent+=total?` · ${Math.min(100,Math.round(loaded/total*100))}% · ${size(loaded)} / ${size(total)}`:` · ${size(loaded)}`;
if(total)this.progressBar.value=Math.min(1,loaded/total);else this.progressBar.removeAttribute("value");
} else this.progressBar.removeAttribute("value");
}
async loadSelected({background=false}={}) {
if(this.destroyed)return;
clearTimeout(this.retryTimer);this.loadController?.abort();
const version=++this.version,controller=this.loadController=new AbortController();
this.reloadButton.disabled=false;this.loadingActive=true;
this.ready=false;this.updateButton();this.schedule();
if(!this.originals.length || !this.results.length){this.loadingActive=false;this.progressBox.hidden=true;return;}
const repetitions=this.results.length/this.originals.length;
const original=this.originals[Math.min(this.originals.length-1,Math.floor(this.index/repetitions))];
const current=()=>version===this.version && !this.destroyed && !controller.signal.aborted;
const blobKey=JSON.stringify([original,this.results[this.index]]);
if(this.blobKey!==blobKey){this.blobKey=blobKey;this.pendingBlobs=[null,null];}
const blobs=this.pendingBlobs||[null,null];
try {
const images = await Promise.all([imageFrom(this.options.resolveUrl(original)), imageFrom(this.options.resolveUrl(this.results[this.index]))]);
if (version !== this.version || this.destroyed) return;
this.before = images[0]; this.after = images[1];
this.ready = true; this.empty.hidden = true;
this.dimensionsLabel.textContent = `${this.before.width} × ${this.before.height}`;
this.status("左原图 / 右重绘 · 滚轮缩放 · 满意后点击采用此结果");
this.updateButton(); this.schedule();
} catch (error) { if (version === this.version && !this.destroyed) this.status(error.message, true); }
let images;
for(let attempt=0;attempt<3;attempt++) {
const states=blobs.map(blob=>({loaded:blob?.size||0,total:blob?.size||0,complete:!!blob}));
this.loading(attempt?T("Retrying comparison images")+` (${attempt+1}/3)`:T("Loading comparison images"),states);
try {
images=await loadPreviewPair([original,this.results[this.index]],{loadPreview:(...args)=>this.options.loadPreview(...args),cached:blobs,signal:controller.signal,
onProgress:(i,state)=>{states[i]=state;if(current())this.loading(T("Loading comparison images"),states);},
onDecoding:()=>{if(current() && states.every(s=>s.complete))this.loading(T("Decoding comparison images…"));}});
break;
} catch(error) {
if(!current())return;
if(attempt===2 || [404,410].includes(error.status))throw error;
await new Promise(resolve=>{const done=()=>{clearTimeout(timer);controller.signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,1000*(attempt+1));controller.signal.addEventListener('abort',done,{once:true});});
}
}
if(!current()){for(const item of images||[])URL.revokeObjectURL(item.url);return;}
if(this.before && (this.before.width!==images[0].image.width || this.before.height!==images[0].image.height))this.viewport.fit();
for(const url of this.previewUrls||[])URL.revokeObjectURL(url);
this.previewUrls=images.map(item=>item.url);this.before=images[0].image;this.after=images[1].image;
this.ready=true;this.pendingBlobs=null;this.retryCount=0;this.empty.hidden=true;this.progressBox.hidden=true;
this.dimensionsLabel.textContent=`${this.before.width} × ${this.before.height}`;
this.status("");this.updateButton();this.schedule();
} catch(error) {
if(!current())return;
this.progressBox.title=error.message;
if([404,410].includes(error.status)){this.progressBox.hidden=true;this.pendingBlobs=null;this.status(T("Result preview expired; run the workflow again"),true);return;}
this.status(T("Image loading failed; retrying automatically. You can also click reload."),true);
const seconds=Math.min(60,5*2**Math.min(this.retryCount||0,4));this.retryCount=(this.retryCount||0)+1;
this.loading(T("Waiting to retry comparison images")+` · ${seconds}s`);
this.retryTimer=setTimeout(()=>this.loadSelected({background:true}),seconds*1000);
} finally {if(version===this.version)this.loadingActive=false;}
}
async adopt() {
this.refreshTargets();
if (!this.ready || !this.targetSelect.value || this.busy) return;
if (this.busy) return;
if (!this.ready || this.pendingOutputs>0) { this.status(T("The result preview is still loading"), true); return; }
if (!this.targetSelect.value) { this.status(T("Select an inline mask to adopt into"), true); return; }
const target = this.targetSelect.value, result = this.results[this.index];
this.busy = true; this.updateButton(); this.status("正在将结果设为新原图…");
this.busy = true; this.updateButton(); this.status(T("Setting the result as the new source image…"));
const progress = ({phase, loaded=0, total=0}={}) => {
const text = phase === 'download' ? T('Downloading result…') : phase === 'upload' ? T('Uploading result…') : phase === 'authenticated' ? T('Uploading with authentication…') : phase === 'processing' ? T('Upload complete, loading image…') : T('Preparing result…');
this.loading(text, phase === 'download' || phase === 'upload' ? [{loaded,total}] : undefined);
};
progress({phase:'prepare'});
const controller=this.adoptController=new AbortController();
try {
await this.options.onAdopt(target, result);
if (!this.destroyed) this.status("已采用:回到内联蒙版,可继续涂抹重绘");
await this.options.onAdopt(target, result,{signal:controller.signal,onProgress:progress});
if (!this.destroyed) this.status(T("Adopted: back in the inline mask, keep painting to inpaint again"));
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally { this.busy = false; if (!this.destroyed) this.updateButton(); }
finally {if(this.adoptController===controller)this.adoptController=null;this.busy = false; if (!this.destroyed) {this.progressBox.hidden=true;this.updateButton();}}
}
transform() {
const rect = this.canvas.getBoundingClientRect();
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 };
if(this.workspaceZoom && this.before)return this.viewport.transform(rect.width,rect.height,this.before.width,this.before.height);
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();
if(this.workspaceZoom){
if(!this.ready)return;
const rect=this.canvas.getBoundingClientRect(),delta=event.deltaY*(event.deltaMode===1?16:event.deltaMode===2?rect.height:1);
this.viewport.zoomAt(rect.width,rect.height,this.before.width,this.before.height,event.clientX-rect.left,event.clientY-rect.top,delta);
if(this.displayMode==='compare')this.move(event);this.schedule();return;
}
this.options.onGraphWheel?.(event);
}
move(event) {
if (!this.ready) return;
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));
if(this.panDrag && this.panDrag.id===event.pointerId){this.viewport.pan(event.clientX-this.panDrag.x,event.clientY-this.panDrag.y);this.panDrag.x=event.clientX;this.panDrag.y=event.clientY;this.schedule();return;}
if(this.displayMode!=='compare')return;
this.hovered = true;
this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
this.schedule();
}
schedule() { if (!this.frame && !this.destroyed) this.frame = requestAnimationFrame(() => { this.frame = 0; this.render(); }); }
enableWorkspaceZoom(enabled) {this.workspaceZoom=!!enabled;this.panDrag=null;this.displayMode='compare';this.viewport.fit();this.lastPaint=null;this.schedule();}
setDisplayMode(mode) {this.displayMode=['compare','before','after'].includes(mode)?mode:'compare';this.lastPaint=null;this.schedule();}
fitView() {this.viewport.fit();this.lastPaint=null;this.schedule();}
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 && !this.workspaceZoom ? 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.before, 0, 0);
ctx.setTransform(1, 0, 0, 1, 0, 0);
this.divider.hidden = !this.ready || !this.hovered || this.displayMode!=='compare';
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 && this.displayMode==='compare') {
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, this.displayMode, 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 (this.displayMode==='compare' && 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.displayMode==='before'?this.before:this.after, 0, 0, this.before.width, this.before.height);
this.lastPaint = { key, hovered: this.hovered, lineX };
if (!this.hovered || this.displayMode!=='compare') { ctx.restore(); return; }
ctx.save(); ctx.beginPath(); ctx.rect(split, 0, this.before.width - split, this.before.height); ctx.clip();
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height); ctx.restore();
ctx.beginPath(); ctx.moveTo(split, 0); ctx.lineTo(split, this.before.height);
ctx.strokeStyle = "#141820"; ctx.lineWidth = 3 / t.scale; ctx.stroke();
ctx.strokeStyle = "#ffe0b8"; ctx.lineWidth = 1 / t.scale; ctx.stroke();
ctx.beginPath(); ctx.arc(split, this.before.height / 2, 7 / t.scale, 0, Math.PI * 2);
ctx.fillStyle = "#272c35"; ctx.fill(); ctx.stroke();
ctx.drawImage(this.before, 0, 0); ctx.restore();
ctx.restore();
}
destroy() {
this.destroyed = true; ++this.version; cancelAnimationFrame(this.frame); this.observer.disconnect();
window.removeEventListener("wheel", this.captureWheel, true);
this.before = this.after = null; this.canvas.width = this.canvas.height = 0; this.root.remove();
this.destroyed = true; ++this.version; clearTimeout(this.retryTimer);this.loadController?.abort();
this.downloadController?.abort();this.adoptController?.abort();
window.removeEventListener("online",this.retryOnOnline);for(const url of this.previewUrls||[])URL.revokeObjectURL(url);
cancelAnimationFrame(this.frame); this.observer.disconnect();
this.pendingBlobs=null;this.before = this.after = null; this.canvas.width = this.canvas.height = 0; this.root.remove();
}
}
+26
View File
@@ -0,0 +1,26 @@
// Store one matching original per result, including batches from older workflows.
export function appendComparison(previous, incoming) {
const pairs = data => {
const originals = Array.isArray(data?.originals) ? data.originals : [];
const results = Array.isArray(data?.results) ? data.results : [];
if (!originals.length || !results.length) return { originals: [], results: [] };
const repetitions = results.length / originals.length;
return {
originals: results.map((_, index) => originals[Math.min(originals.length - 1, Math.floor(index / repetitions))]),
results,
};
};
const before = pairs(previous), next = pairs(incoming);
const history = { originals: [], results: [] }, seen = new Set();
for (const data of [before, next]) data.results.forEach((result, index) => {
// Cached execution notifications can replay the same preview. Its file
// identity is stable, while a genuinely new generation gets a new file.
const key = typeof result === "object" && result !== null
? JSON.stringify([result.type || "temp", result.subfolder || "", result.filename])
: JSON.stringify(result);
if (seen.has(key)) return;
seen.add(key);
history.originals.push(data.originals[index]); history.results.push(result);
});
return history;
}
+70 -30
View File
@@ -1,72 +1,112 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ImageCompareView } from "./compare.js";
import { ImageCompareView } from "./compare.js?v=toolbar-layout-20261112";
import { readPreview } from "./preview_transport.js";
import { installComparisonRecovery } from "./result_recovery.js";
import {executionId,executionNode,graphNodeEntries} from "./workspace_flow.js";
import {upstreamNodes} from "./workbench_analysis.js";
import { appendComparison } from "./compare_history.js?v=0.5.8";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth, createWidgetHost } from "./widget_layout.js?v=stable-host-20261112";
function viewUrl(image) {
function viewPath(image) {
if (image?.type === "adachi_memory") return `/adachi-inpaint/compare/${encodeURIComponent(image.filename || "")}`;
const type = ["input", "output", "temp"].includes(image?.type) ? image.type : "temp";
return api.apiURL(`/view?${new URLSearchParams({ filename: image?.filename || "", subfolder: image?.subfolder || "", type })}`);
return `/view?${new URLSearchParams({ filename: image?.filename || "", subfolder: image?.subfolder || "", type })}`;
}
const viewUrl = image => api.apiURL(viewPath(image));
function targets(node) {
const graph = node.graph;
const editors = (graph?._nodes || []).filter(candidate => candidate.type === "AdachiInlineMask" && candidate.adachiUseResult)
.map(candidate => ({ id: candidate.id, label: `${candidate.title || "内联蒙版"} #${candidate.id}` }));
const sourceLink = graph?.links?.[node.inputs?.find(input => input.name === "original")?.link];
const queue = sourceLink ? [sourceLink.origin_id] : [], seen = new Set(), upstream = new Set();
while (queue.length && seen.size < 256) {
const id = queue.shift(); if (seen.has(id)) continue; seen.add(id);
const current = graph.getNodeById(id); if (!current) continue;
if (current.type === "AdachiInlineMask") { upstream.add(String(id)); continue; }
for (const input of current.inputs || []) {
if (!["IMAGE", "*"].includes(input.type)) continue;
const link = graph.links?.[input.link]; if (link) queue.push(link.origin_id);
}
}
const graph=app.rootGraph||app.graph;
const editors=graphNodeEntries(graph).filter(({node})=>node.type==='AdachiInlineMask' && node.adachiUseResult)
.map(({node:editor,id})=>({id,label:`${editor.title||T('Inline Mask')} #${id}`}));
const upstream=new Set([...upstreamNodes(node,'original')].filter(n=>n.type==='AdachiInlineMask').map(n=>executionId(graph,n)));
return { editors, preferred: upstream.size === 1 ? [...upstream][0] : "" };
}
app.registerExtension({
name: "Adachi.ImageCompare",
setup() { installComparisonRecovery(api,()=>app.rootGraph||app.graph); },
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "AdachiImageCompare") return;
const created = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const value = created?.apply(this, arguments), node = this;
const recovery=installComparisonRecovery(api,()=>app.rootGraph||app.graph);
node.adachiTrackPrompt=(id,graph=app.rootGraph||app.graph)=>recovery.track(graph,id,executionId(graph,node));
const compare = new ImageCompareView({
resolveUrl: viewUrl,
loadPreview: (image,options)=>readPreview(api.fetchApi.bind(api),viewPath(image),options),
recover: ()=>recovery.recover(),
waitingText: T("Waiting for the generated image…"),
failedText: T("Generation failed; check the ComfyUI task history"),
onGraphWheel: event => app.canvas?.processMouseWheel(event),
getTargets: () => targets(node),
onSelect: index => { node.properties.adachi_result_index = index; },
async onAdopt(id, image) {
const target = node.graph?.getNodeById(id);
if (!target?.adachiUseResult) throw new Error("找不到接收结果的内联蒙版节点");
const response = await api.fetchApi(`/view?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || "", type: image.type || "temp" })}`);
if (!response.ok) throw new Error("结果预览已过期,请重新运行工作流");
const blob = await response.blob();
if (blob.size > 64 * 1024 * 1024) throw new Error("结果图片超过 64 MiB,请先缩小");
await target.adachiUseResult(new File([blob], `adachi-result-${Date.now()}.png`, { type: "image/png" }));
async fetchResult(image, options) {
return readPreview(api.fetchApi.bind(api),viewPath(image),options);
},
async onAdopt(id, image, options={}) {
const graph=app.rootGraph||app.graph,target = executionNode(graph,id);
if (!target?.adachiUseResult) throw new Error(T("No inline mask node is available to receive the result"));
options.onProgress?.({phase:'download'});
const blob = await readPreview(api.fetchApi.bind(api),viewPath(image),{...options,onProgress:p=>options.onProgress?.({phase:'download',...p}),maxBytes:64*1024*1024});
options.signal?.throwIfAborted();
if(graph!==(app.rootGraph||app.graph)||target!==executionNode(graph,id))throw Error(T("The workflow changed; check connections again"));
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" }), image.adachi_origin, options);
// 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 widget = node.addDOMWidget("adachi_compare", "adachi_compare_view", compare.root, {
const stopWheelRouting = routeWheelOutsideImage(compare.root, null, () => false);
const widgetHost = createWidgetHost(compare.root);
const widget = node.addDOMWidget("adachi_compare", "adachi_compare_view", widgetHost, {
serialize: false, hideOnZoom: false, getMinHeight: () => 420, getMaxHeight: () => 4096,
});
widget.serialize = false;
bindWidgetToNodeWidth(node, widget, widgetHost);
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.map(image => output.adachi_origin?.[0]
? {...image, adachi_origin:output.adachi_origin[0]} : image),
});
node.properties.adachi_compare = data;
compare.setData(data, node.properties.adachi_result_index || 0);
node.properties.adachi_latest_result = data.results[data.results.length - 1];
compare.setLatestResult(node.properties.adachi_latest_result);
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);
compare.setLatestResult(node.properties.adachi_latest_result || node.properties.adachi_compare.results.at(-1));
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;
node.onRemoved = function () { compare.destroy(); return removed?.apply(this, arguments); };
node.onRemoved = function () { stopWheelRouting(); compare.destroy(); return removed?.apply(this, arguments); };
node.setSize([Math.max(550, node.size[0]), Math.max(550, node.size[1])]);
return value;
};
+22
View File
@@ -0,0 +1,22 @@
/* SPDX-License-Identifier: GPL-3.0-only */
// Shared viewport for sliding comparison and single-image modes. Coordinates
// are CSS pixels relative to the visible stage, not physical canvas pixels.
export class CompareViewport {
constructor() { this.fit(); }
fit() { this.zoom=1; this.panX=0; this.panY=0; }
transform(width,height,imageWidth,imageHeight) {
const base=Math.min(width/imageWidth,height/imageHeight),scale=base*this.zoom;
return {width,height,scale,x:(width-imageWidth*scale)/2+this.panX,y:(height-imageHeight*scale)/2+this.panY};
}
zoomAt(width,height,imageWidth,imageHeight,x,y,deltaY) {
if(!(width>0&&height>0&&imageWidth>0&&imageHeight>0))return;
const current=this.transform(width,height,imageWidth,imageHeight);
const next=Math.max(0.1,Math.min(16,this.zoom*Math.exp(-Math.max(-240,Math.min(240,deltaY))*.0015)));
const ratio=next/this.zoom;
const nextX=x-(x-current.x)*ratio,nextY=y-(y-current.y)*ratio;
this.zoom=next;
const centered=this.transform(width,height,imageWidth,imageHeight);
this.panX+=nextX-centered.x;this.panY+=nextY-centered.y;
}
pan(dx,dy) {this.panX+=dx;this.panY+=dy;}
}
+27
View File
@@ -0,0 +1,27 @@
export const CROP_PRESETS = [
[1024, 1536],
[512, 512],
[768, 1024],
[1024, 1024],
[1536, 2048],
[2048, 2048],
];
export function cropAt(width, height, cropWidth, cropHeight, x, y) {
if (
![width, height, cropWidth, cropHeight, x, y].every(Number.isFinite) ||
cropWidth < 32 ||
cropHeight < 32 ||
cropWidth % 32 ||
cropHeight % 32 ||
cropWidth > width ||
cropHeight > height
)
return null;
return {
x: Math.max(0, Math.min(width - cropWidth, Math.floor(x))),
y: Math.max(0, Math.min(height - cropHeight, Math.floor(y))),
width: cropWidth,
height: cropHeight,
};
}
+108
View File
@@ -0,0 +1,108 @@
// Probe only our own temporary key. Existing ComfyUI drafts are never edited or deleted.
export const inlineWidgetNames = [
"image", "mask_data", "source_data", "edit_mode", "crop_state", "blend_data", "layer_image", "paint_data",
];
// Some extensions also serialize a named snapshot. Keep it in step with the
// actual widgets, otherwise a removed embedding can survive in that snapshot.
export function syncInlineWidgets(data, widgets) {
for (const name of inlineWidgetNames) {
const index = widgets.findIndex((widget) => widget.name === name);
if (index < 0) continue;
if (Array.isArray(data.widgets_values)) data.widgets_values[index] = widgets[index].value;
if (data.widgets_values_named && typeof data.widgets_values_named === "object")
data.widgets_values_named[name] = widgets[index].value;
}
}
function inlineNodes(graph, seen = new Set()) {
if (!graph || seen.has(graph)) return [];
seen.add(graph);
return [
...(graph.nodes || []).filter((node) => node.type === "AdachiInlineMask"),
...(graph.definitions?.subgraphs || []).flatMap((subgraph) => inlineNodes(subgraph, seen)),
];
}
// Called before graph configuration: native autosave must never see the large
// source first, since a quota failure disables its storage for the entire tab.
export async function prepareInlineDraft(graph, { fits, upload }) {
const nodes = inlineNodes(graph);
for (const node of nodes) {
if (!Array.isArray(node.widgets_values)) continue;
for (const [index, name] of inlineWidgetNames.entries()) {
if (index < node.widgets_values.length && node.widgets_values_named)
node.widgets_values_named[name] = node.widgets_values[index];
}
}
const candidates = nodes.flatMap(node => [[2,"source_data"],[7,"paint_data"]].map(([index,name]) => ({node,index,name,embedded:node.widgets_values?.[index] ?? node.widgets_values_named?.[name]})))
.filter(({embedded})=>typeof embedded === "string" && embedded.startsWith("data:") && embedded.length >= 256 * 1024)
.sort((a,b)=>b.embedded.length-a.embedded.length);
let converted = 0;
for (const {node,index,name,embedded} of candidates) {
if (fits(graph) !== false) break;
const filename = await upload(embedded);
if (typeof filename !== "string" || !filename) throw new Error("Source upload returned no filename");
// Commit only after a successful exact upload. All masks and layers stay intact.
if (Array.isArray(node.widgets_values)) {
if(name === "source_data") node.widgets_values[0] = filename;
node.widgets_values[index] = name === "source_data" ? "" : filename;
}
if (node.widgets_values_named) {
if(name === "source_data") node.widgets_values_named.image = filename;
node.widgets_values_named[name] = name === "source_data" ? "" : filename;
}
converted++;
}
return converted;
}
export function canStoreDraft(
graph,
{ path = "", workspace = "personal", storage } = {},
) {
try {
storage ??= globalThis.localStorage;
} catch {
return null;
}
const serialized = JSON.stringify({
data: JSON.stringify(graph),
updatedAt: Date.now(),
});
let credit = 0;
try {
if (path) {
const index = JSON.parse(
storage.getItem(`Comfy.Workflow.DraftIndex.v2:${workspace}`) || "null",
);
if (
Object.values(index?.entries || {}).some((entry) => entry.path === path)
) {
// Standard FNV-1a path hash used by the current ComfyUI draft format.
let hash = 2166136261;
for (let i = 0; i < path.length; i++)
hash = Math.imul(hash ^ path.charCodeAt(i), 16777619);
const key = `Comfy.Workflow.Draft.v2:${workspace}:${(hash >>> 0).toString(16).padStart(8, "0")}`;
credit = storage.getItem(key)?.length || 0;
}
}
} catch {
/* Unknown metadata: a conservative probe still preserves all existing drafts. */
}
const needed = Math.max(0, serialized.length - credit);
if (!needed) return true;
const key = `Adachi.DraftProbe:${crypto.randomUUID()}`;
try {
storage.setItem(key, " ".repeat(needed));
return true;
} catch (error) {
return error?.name === "QuotaExceededError" ? false : null;
} finally {
try {
storage.removeItem(key);
} catch {
/* Storage may be unavailable. */
}
}
}
+105 -1
View File
@@ -6,7 +6,7 @@
}
.dd-inpaint *, .dd-inpaint *::before, .dd-inpaint *::after { box-sizing: border-box; }
.dd-inpaint:focus-within { border-color: #9f7950; }
.dd-inpaint .dd-head { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: space-between; padding: 11px 12px 6px; align-items: center; }
.dd-inpaint .dd-head { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: flex-end; padding: 6px 12px 2px; align-items: center; }
.dd-inpaint .dd-brand { font-size: 11px; letter-spacing: 1.5px; font-weight: 700; color: var(--dd-accent); }
.dd-inpaint .dd-dimensions, .dd-inpaint .dd-zoom { font-size: 11px; color: var(--dd-muted); font-variant-numeric: tabular-nums; }
.dd-inpaint .dd-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 5px 10px; }
@@ -26,7 +26,14 @@
.dd-inpaint input[type="range"] { display: block; width: 100%; min-width: 0; height: 18px; margin: 3px 0 0; accent-color: #f3b06a; cursor: pointer; }
.dd-inpaint .dd-stage { position: relative; flex: 1 1 290px; min-height: 170px; overflow: hidden; border-top: 1px solid var(--dd-line); border-bottom: 1px solid var(--dd-line); background-color: #161a20; background-image: linear-gradient(45deg, #222831 25%, transparent 25%), linear-gradient(-45deg, #222831 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #222831 75%), linear-gradient(-45deg, transparent 75%, #222831 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0; }
.dd-inpaint .dd-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: crosshair; }
.dd-inpaint .dd-color-paint {display:flex;flex:none;gap:3px;align-items:center;}
.dd-inpaint .dd-color-paint button {min-width:26px;padding:3px 6px;}
.dd-inpaint .dd-color-paint input[type=color] {width:26px;height:25px;padding:1px;border:0;background:transparent;cursor:pointer;}
.dd-inpaint .dd-color-paint [hidden] {display:none;}
.dd-inpaint .dd-stage.dd-color-active canvas {cursor:crosshair;}
.dd-inpaint .dd-stage.dd-pan canvas { cursor: grab; }
.dd-inpaint .dd-stage.dd-erasing canvas { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cpath d='M6 19 18 7a3 3 0 0 1 4 0l5 5a3 3 0 0 1 0 4L16 27h-5l-5-5a2 2 0 0 1 0-3Z' fill='%23ffb3c0' stroke='%23151a22' stroke-width='3' stroke-linejoin='round'/%3E%3Cpath d='m7 19 7-7 10 10-5 5h-8l-4-4a3 3 0 0 1 0-4Z' fill='%23fff4f6'/%3E%3Cpath d='m13 13 10 10M11 28h17' fill='none' stroke='%23151a22' stroke-width='2'/%3E%3C/svg%3E") 16 16, crosshair; }
.dd-inpaint .dd-stage.dd-color-active.dd-erasing canvas {cursor:crosshair;}
.dd-inpaint .dd-stage.dd-drop { outline: 3px solid var(--dd-accent); outline-offset: -3px; }
.dd-inpaint .dd-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; gap: 6px; color: var(--dd-muted); padding: 15px; text-align: center; }
.dd-inpaint .dd-empty[hidden] { display: none; }
@@ -40,7 +47,32 @@
.dd-inpaint .dd-embed { display: flex; align-items: center; gap: 5px; padding: 4px 10px 0; font-size: 11px; color: var(--dd-muted); cursor: pointer; }
.dd-inpaint .dd-embed input { accent-color: #e9ab68; margin: 0; }
.dd-inpaint .dd-status { padding: 5px 11px 8px; color: var(--dd-muted); font-size: 10px; min-height: 27px; overflow-wrap: anywhere; }
.dd-inpaint .dd-upload-progress { display: flex; flex-direction: column; gap: 4px; padding: 7px 9px; color: var(--dd-text); background: #20242bee; border-radius: 4px; font-size: 11px; pointer-events: none; }
.dd-inpaint .dd-upload-bar { width: 100%; height: 6px; accent-color: #f3b06a; }
.dd-inline-minimal > .dd-upload-progress { position: absolute; z-index: 3; top: 34px; left: 5px; width: min(240px, calc(100% - 10px)); }
.dd-inpaint .dd-error { color: #ff9c92; }
.dd-inpaint [hidden] { display: none !important; }
.dd-inpaint .dd-parameters { flex: 0 0 auto; padding: 10px 12px 8px; border-top: 1px solid var(--dd-line); }
.dd-inpaint .dd-param-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-bottom: 7px; }
.dd-inpaint .dd-param-header strong { font-weight: 500; font-size: 12px; }
.dd-inpaint .dd-param-placeholder { color: var(--dd-muted); padding-block: 4px; font-size: 11px; }
.dd-inpaint .dd-param-body { display: flex; flex-direction: column; gap: 8px; }
.dd-inpaint .dd-param-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.dd-inpaint .dd-param-label { color: var(--dd-muted); font-size: 11px; }
.dd-inpaint .dd-param-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.dd-inpaint .dd-param-pills { display: flex; flex-wrap: wrap; gap: 4px; }
.dd-inpaint .dd-param-pills button { flex: 1; font-size: 11px; padding: 5px 7px; }
.dd-inpaint .dd-param-number-label { display: flex; align-items: center; justify-content: space-between; gap: 5px; color: var(--dd-muted); font-size: 11px; }
.dd-inpaint .dd-param-field input[type="number"] { width: 86px; min-width: 0; padding: 4px 5px; border: 1px solid var(--dd-line); border-radius: 4px; background: var(--dd-panel); color: var(--dd-text); font: inherit; font-variant-numeric: tabular-nums; }
.dd-inpaint .dd-param-field select, .dd-inpaint .dd-param-header select { min-width: 0; max-width: 100%; color: var(--dd-text); background: var(--dd-panel); border: 1px solid var(--dd-line); border-radius: 4px; padding: 5px; font: inherit; }
.dd-inpaint .dd-param-field:has(input[aria-label="Seed"]) input { width: 130px; }
.dd-inpaint .dd-param-resolution { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.dd-inpaint .dd-param-resolution button { font-size: 11px; }
.dd-inpaint .dd-param-advanced { border-top: 1px solid var(--dd-line); padding-top: 7px; }
.dd-inpaint .dd-param-advanced summary { color: var(--dd-muted); cursor: pointer; font-size: 11px; }
.dd-inpaint .dd-param-advanced-body { display: flex; flex-direction: column; gap: 8px; padding-top: 10px; }
.dd-inpaint .dd-param-toggle { display: flex; gap: 6px; align-items: center; color: var(--dd-muted); font-size: 11px; }
.dd-inpaint .dd-param-toggle input { margin: 0; accent-color: #e9ab68; }
.dd-inpaint .dd-resize-handle { flex: 0 0 18px; display: flex; align-items: center; justify-content: center; color: var(--dd-muted); background: var(--dd-panel); border-top: 1px solid var(--dd-line); cursor: ns-resize; touch-action: none; font-size: 14px; line-height: 1; }
.dd-inpaint .dd-resize-handle:hover, .dd-inpaint .dd-resize-handle:focus-visible { color: var(--dd-accent); background: #39424e; outline: none; }
.dd-comparer .dd-compare-toolbar, .dd-comparer .dd-adoption { flex-wrap: nowrap; }
@@ -51,6 +83,78 @@
.dd-comparer .dd-adoption select { flex: 1; }
.dd-comparer .dd-compare-stage { min-height: 220px; }
.dd-comparer .dd-compare-stage canvas { cursor: ew-resize; }
.dd-inline-minimal { position: relative; border: 0; border-radius: 0; background: transparent; }
.dd-inline-minimal .dd-stage { border: 0; min-height: 170px; flex-basis: 0; }
.dd-inline-minimal > .dd-files { position: absolute; z-index: 2; left: 5px; top: 5px; padding: 0; }
.dd-inline-minimal > .dd-files button { min-height: 23px; padding: 2px 6px; font-size: 11px; opacity: .85; }
.dd-inline-minimal > .dd-files button:hover { opacity: 1; }
.dd-inline-minimal > .dd-resize-handle { position: absolute; z-index: 2; bottom: 0; width: 100%; height: 5px; min-height: 5px; border: 0; color: transparent; background: transparent; }
.dd-inline-minimal > .dd-resize-handle:hover { background: #ffc07966; }
.dd-inline-minimal > .dd-status:not([hidden]) { position: absolute; bottom: 6px; left: 0; right: 0; background: #20242bee; }
.dd-inline-minimal.dd-crop-mode > .dd-status:not([hidden]) { bottom: 40px; }
.dd-editor-controls { height: auto; flex: 0 0 auto; border: 0; border-radius: 0; background: transparent; overflow: visible; }
.dd-editor-controls .dd-source-info { justify-content: space-between; }
.dd-editor-controls .dd-filename { padding-left: 0; flex: 1; }
.dd-workbench { height: 100%; border: 0; border-radius: 0; }
.dd-workbench-tools { flex: 0 0 auto; }
.dd-tools-placeholder { padding: 8px 12px; }
.dd-comparer { border: 0; border-radius: 0; }
.dd-comparer .dd-compare-stage { border: 0; }
.dd-comparer .dd-compare-toolbar { padding-top: 5px; padding-bottom: 2px; }
.dd-comparer .dd-compare-toolbar .dd-dimensions { margin-left: 5px; }
.adachi-workspace .dd-comparer .dd-compare-fit { display: none; }
.dd-comparer .dd-adoption { padding-top: 3px; padding-bottom: 5px; }
.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-actions { display: flex; align-items: center; gap: 5px; }
.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-body.dd-gp-collapsed { grid-template-columns: minmax(0, 1fr); }
.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; }
/* Compact controls shared by the mask and crop/blend modes. */
.dd-inline-minimal .dd-mode-select { position:absolute; z-index:3; top:5px; right:5px; max-width:42%; color:var(--dd-text); background:#252b34e8; border:1px solid var(--dd-line); border-radius:4px; height:23px; font:11px "Segoe UI","Microsoft YaHei",sans-serif; padding:1px 3px; pointer-events:auto; }
.dd-inline-minimal .dd-crop-bar { position:absolute; z-index:3; bottom:7px; left:5px; right:5px; display:flex; gap:3px; flex-wrap:wrap; align-items:center; width:fit-content; max-width:calc(100% - 10px); background:#20242be8; border-radius:4px; padding:3px; pointer-events:auto; }
.dd-inline-minimal .dd-crop-bar button { min-height:23px; height:23px; padding:1px 6px; min-width:24px; }
.dd-inline-minimal .dd-crop-bar select,.dd-inline-minimal .dd-crop-custom input { color:var(--dd-text); background:var(--dd-panel); border:1px solid var(--dd-line); border-radius:3px; height:23px; font:11px "Segoe UI","Microsoft YaHei",sans-serif; max-width:112px; }
.dd-inline-minimal .dd-crop-custom { display:flex; gap:3px; }
.dd-inline-minimal .dd-crop-custom input { width:66px; }
.dd-inline-minimal > .dd-files { right:auto; }
.dd-workbench .dd-workbench-fold {flex:0 0 auto;border-bottom:1px solid var(--dd-line);}
.dd-workbench .dd-workbench-fold:not([open]) > :not(summary) {display:none !important;}
.dd-workbench .dd-workbench-fold>summary {padding:6px 10px;color:var(--dd-muted);font-size:11px;cursor:pointer;list-style:none;display:flex;gap:6px;align-items:center;}
.dd-workbench .dd-workbench-fold>summary::before {content:"▸";font-size:10px;color:var(--dd-accent);}
.dd-workbench .dd-workbench-fold[open]>summary::before {content:"▾";}
.dd-workbench .dd-workbench-fold>summary:hover {color:var(--dd-text);background:#ffffff05;}
.dd-workbench .dd-workbench-fold>summary:focus-visible {outline:1px solid var(--dd-accent);outline-offset:-2px;}
.dd-workbench .dd-param-mask-settings {display:flex;flex-direction:column;gap:8px;padding:7px 12px 9px;}
.dd-workbench .dd-workbench-actions {flex:0 0 auto;}
.dd-workbench .dd-workbench-actions:empty {display:none;}
.dd-workbench .dd-workbench-actions .dd-actions {padding:6px 10px;gap:4px;}
.dd-workbench .dd-workbench-actions .dd-actions button {min-height:24px;padding:3px 7px;}
/* Network progress belongs to the image surface, never to the graph canvas. */
.dd-comparer .dd-preview-progress {position:absolute;left:10px;right:10px;bottom:12px;z-index:4;max-width:380px;padding:9px 12px;border:1px solid var(--dd-line);border-radius:5px;background:var(--dd-bg);color:var(--dd-text);pointer-events:none;font-size:12px;}
.dd-comparer .dd-preview-progress progress {display:block;width:100%;height:7px;margin-top:6px;accent-color:var(--dd-accent);}
+263 -78
View File
@@ -1,4 +1,8 @@
/* Original implementation, MIT licensed. No WebUI source is bundled. */
/* SPDX-License-Identifier: GPL-3.0-only
* Original implementation. No WebUI source is bundled. */
import { registerImageWheel } from "./wheel_gestures.js";
import { T } from "./i18n.js";
import { loadImage } from "./image_load.js";
const el = (tag, cls, text) => {
const node = document.createElement(tag);
if (cls) node.className = cls;
@@ -6,12 +10,6 @@ const el = (tag, cls, text) => {
return node;
};
const makeCanvas = () => document.createElement("canvas");
const loadImage = (url) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("无法读取图像 / Cannot load image"));
image.src = url;
});
export class InlineMaskEditor {
constructor(options = {}) {
@@ -22,6 +20,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;
@@ -40,18 +40,38 @@ export class InlineMaskEditor {
this.observer.observe(this.stage);
// Nodes 2.0 captures wheel events before widget handlers. Route only
// this canvas at window capture so image gestures stay inside it.
this.captureWheel = event => { if (event.target === this.canvas) this.wheel(event); };
window.addEventListener("wheel", this.captureWheel, { capture: true, passive: false });
this.stopWheelCapture = registerImageWheel(this.canvas, () => this.ready, event => this.wheel(event), options.onGraphWheel);
this.modifierDown = event => {
if (event.key !== "Alt" || this.colorPaint?.active || !this.ready || !this.canvas.matches(":hover")) return;
event.preventDefault(); this.altErase = true; this.updateButtons(); this.schedule();
};
this.modifierUp = event => {
if (event.type !== "blur" && event.key !== "Alt") return;
this.altErase = false; this.updateButtons(); this.schedule();
};
window.addEventListener("keydown", this.modifierDown, true);
window.addEventListener("keyup", this.modifierUp, true);
window.addEventListener("blur", this.modifierUp);
this.root.addEventListener("keydown", (event) => this.keydown(event));
this.root.addEventListener("keyup", (event) => {
if (event.code === "Space") { this.space = false; this.stage.classList.remove("dd-pan"); }
if (event.key === "Alt") { this.altErase = false; this.updateButtons(); this.schedule(); }
});
this.root.addEventListener("blur", () => { this.space = false; this.altErase = false; this.updateButtons(); this.schedule(); }, true);
this.root.addEventListener("paste", (event) => {
const paste = event => {
const file = [...(event.clipboardData?.files || [])].find(f => f.type.startsWith("image/"));
if (file) { event.preventDefault(); event.stopPropagation(); this.upload(file); }
});
};
this.root.addEventListener("paste", paste);
if (this.controls) {
this.controls.addEventListener("paste", paste);
this.controls.addEventListener("blur", () => { this.space = false; this.altErase = false; this.updateButtons(); this.schedule(); }, true);
this.controls.addEventListener("keydown", event => this.keydown(event));
this.controls.addEventListener("keyup", event => {
if (event.code === "Space") { this.space = false; this.stage.classList.remove("dd-pan"); }
if (event.key === "Alt") { this.altErase = false; this.updateButtons(); this.schedule(); }
});
}
}
button(parent, label, title, action, cls = "") {
@@ -66,13 +86,13 @@ export class InlineMaskEditor {
build() {
const head = el("div", "dd-head");
head.append(el("span", "dd-brand", "ADACHI WORKBENCH"));
this.sizeLabel = el("span", "dd-dimensions", "内联蒙版 · INLINE MASK");
this.sizeLabel = el("span", "dd-dimensions", T("INLINE MASK"));
head.append(this.sizeLabel);
this.root.append(head);
const files = el("div", "dd-row dd-files");
this.button(files, "载入原图", "上传图片,也可拖放或粘贴 / Upload image", () => this.imageInput.click(), "dd-primary");
this.filename = el("span", "dd-filename", "拖放图片到画布");
this.loadButton = this.button(files, "↑", T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
this.downloadButton = this.button(files, "↓", T("Download the source image"), () => this.downloadSource(), "dd-primary");
this.filename = el("span", "dd-filename", T("Drop an image onto the canvas"));
files.append(this.filename);
this.root.append(files);
this.imageInput = el("input");
@@ -98,32 +118,40 @@ export class InlineMaskEditor {
const tools = el("div", "dd-row dd-tools");
this.toolButtons = {};
for (const [name, label, title] of [
["brush", "画笔", "画笔 B / Brush"], ["erase", "橡皮", "橡皮 E,按住 Alt 或鼠标右键临时擦除 / Eraser"],
["rect", "矩形", "矩形 R / Rectangle"], ["lasso", "套索", "自由套索 L / Freehand lasso"],
["pan", "移动", "移动 H,或按住空格 / Pan"],
]) this.toolButtons[name] = this.button(tools, label, title, () => this.setTool(name));
["brush", "Brush", "Brush (B)"], ["erase", "Eraser", "Eraser (E); hold Alt for temporary erase"],
["rect", "Rectangle", "Rectangle (R)"], ["lasso", "Lasso", "Freehand lasso (L)"],
["pan", "Pan", "Pan (H), or hold Space"],
]) this.toolButtons[name] = this.button(tools, T(label), T(title), () => this.setTool(name));
this.root.append(tools);
const settings = el("div", "dd-settings");
this.addSlider(settings, "笔径", "brushSize", 1, 512, "px");
this.addSlider(settings, "硬度", "hardness", 0, 100, "%");
this.addSlider(settings, "笔触", "brushOpacity", 1, 100, "%");
this.addSlider(settings, "叠加", "overlayOpacity", 0, 100, "%");
this.addSlider(settings, T("Brush size"), "brushSize", 1, 512, "px");
this.addSlider(settings, T("Hardness"), "hardness", 0, 100, "%");
this.addSlider(settings, T("Brush opacity"), "brushOpacity", 1, 100, "%");
this.addSlider(settings, T("Overlay"), "overlayOpacity", 0, 100, "%");
this.stage = el("div", "dd-stage");
this.canvas = makeCanvas();
this.canvas.setAttribute("aria-label", "直接涂抹蒙版;橙色区域将被重绘 / Inline mask painting canvas");
this.canvas.setAttribute("aria-label", T("Inline mask painting canvas; orange areas are repainted"));
this.stage.append(this.canvas);
this.empty = el("div", "dd-empty");
this.empty.append(el("span", "dd-empty-symbol", "+"), el("strong", "", "把原图拖到这里"), el("span", "", "直接在节点中涂抹需要重绘的区域"));
this.empty.append(el("span", "dd-empty-symbol", "+"), el("strong", "", T("Drop an image here")), el("span", "", T("Paint the areas to repaint directly in this node")));
this.stage.append(this.empty);
this.root.append(this.stage, settings);
this.canvas.oncontextmenu = event => event.preventDefault();
this.uploadIndicator = el("div", "dd-upload-progress");
this.uploadIndicator.hidden = true;
this.uploadIndicator.setAttribute("role", "status");
this.uploadIndicator.setAttribute("aria-live", "polite");
this.uploadLabel = el("span", "dd-upload-label");
this.uploadBar = el("progress", "dd-upload-bar");
this.uploadBar.max = 100;
this.uploadBar.setAttribute("aria-label", T("Image upload progress"));
this.uploadIndicator.append(this.uploadLabel, this.uploadBar);
this.root.append(this.stage, settings, this.uploadIndicator);
this.canvas.oncontextmenu = event => { event.preventDefault(); event.stopPropagation(); };
this.canvas.addEventListener("pointerdown", event => this.pointerDown(event));
this.canvas.addEventListener("pointermove", event => this.pointerMove(event));
this.canvas.addEventListener("pointerup", event => this.pointerUp(event));
this.canvas.addEventListener("pointercancel", event => this.pointerUp(event));
this.canvas.addEventListener("lostpointercapture", event => this.pointerUp(event));
this.canvas.addEventListener("pointerleave", () => { this.cursor = null; this.schedule(); });
this.canvas.addEventListener("wheel", event => this.wheel(event), { passive: false });
this.stage.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); this.stage.classList.add("dd-drop"); });
this.stage.addEventListener("dragleave", () => this.stage.classList.remove("dd-drop"));
this.stage.addEventListener("drop", event => {
@@ -132,33 +160,57 @@ export class InlineMaskEditor {
if (file) this.upload(file);
});
const actions = el("div", "dd-row dd-actions");
this.undoButton = this.button(actions, "撤销", "撤销 Ctrl+Z / Undo", () => this.undo());
this.redoButton = this.button(actions, "重做", "重做 Ctrl+Shift+Z / Redo", () => this.redo());
this.button(actions, "反转", "反转绘制的蒙版 / Invert painted mask", () => this.action({ type: "invert" }));
this.button(actions, "全选", "重绘整张图 / Fill mask", () => this.action({ type: "fill" }));
this.button(actions, "清空", "清空蒙版 / Clear mask", () => this.action({ type: "clear" }));
this.button(actions, "导入蒙版", "白色重绘,黑色保护 / Import mask", () => { if (this.ready) this.maskInput.click(); });
this.button(actions, "导出", "导出原始分辨率黑白蒙版 / Export mask PNG", () => this.download());
this.actions = actions;
this.undoButton = this.button(actions, T("Undo"), T("Undo (Ctrl+Z)"), () => this.undo());
this.redoButton = this.button(actions, T("Redo"), T("Redo (Ctrl+Shift+Z)"), () => this.redo());
this.button(actions, T("Invert"), T("Invert the painted mask"), () => this.action({ type: "invert" }));
this.button(actions, T("Fill"), T("Fill the whole image with mask"), () => this.action({ type: "fill" }));
this.button(actions, T("Clear"), T("Clear the mask"), () => this.action({ type: "clear" }));
this.button(actions, T("Import mask"), T("White repaints, black preserves"), () => { if (this.ready) this.maskInput.click(); });
this.button(actions, T("Export"), T("Export the mask PNG at full resolution"), () => this.download());
this.root.append(actions);
const footer = el("div", "dd-row dd-viewbar");
this.viewButtons = {};
for (const [name, label] of [["overlay", "叠加"], ["image", "原图"], ["mask", "蒙版"]]) {
this.viewButtons[name] = this.button(footer, label, `${label} / ${name}`, () => { this.view = name; this.updateButtons(); this.schedule(); });
for (const [name, label] of [["overlay", "Overlay"], ["image", "Image"], ["mask", "Mask"]]) {
const text = T(label);
this.viewButtons[name] = this.button(footer, text, `${text} / ${name}`, () => { this.view = name; this.updateButtons(); this.schedule(); });
}
const spacer = el("span", "dd-spacer");
footer.append(spacer);
this.zoomLabel = el("span", "dd-zoom", "100%");
footer.append(this.zoomLabel);
this.button(footer, "适应", "适应画布 / Fit image", () => this.fit());
this.fitButton = this.button(footer, "⛶", T("Fit the image to the canvas"), () => this.fit());
this.keepButton = this.button(footer, "", T("Pin the current view"), 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");
this.embed.type = "checkbox";
this.embed.onchange = () => this.options.onEmbed?.(this.embed.checked);
embedRow.append(this.embed, document.createTextNode("原图嵌入工作流 · 分享时无需另传原图"));
embedRow.append(this.embed, document.createTextNode(T("Embed the source image in the workflow; no separate file needed when sharing")));
this.root.append(embedRow);
this.message = el("div", "dd-status", "Alt 临时擦除 · Ctrl+滚轮调笔径 · 滚轮缩放 / 空格拖动");
this.message = el("div", "dd-status", T("Alt = temporary erase · Ctrl+wheel = brush size · wheel = zoom · Space = pan"));
this.message.hidden = true;
this.message.setAttribute("role", "alert");
this.root.append(this.message);
if (this.options.compact) {
this.root.classList.add("dd-inline-minimal");
this.empty.hidden = true;
this.controls = el("div", "dd-inpaint dd-editor-controls");
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);
actions.addEventListener("keydown", event => this.keydown(event));
this.loadButton.after(this.fitButton);
this.fitButton.after(this.keepButton);
this.loadButton.after(this.downloadButton);
// These are the original live controls; the sampler mounts this
// same DOM tree so brush state and callbacks are preserved.
}
this.setTool("brush");
this.updateButtons();
}
@@ -180,24 +232,58 @@ export class InlineMaskEditor {
status(message, error = false) {
this.message.textContent = message;
this.message.classList.toggle("dd-error", error);
// Only errors stay visible; transient hints and progress lines hide.
this.message.hidden = !error;
}
notice(message) {
// An explicit non-error notice that should remain visible.
this.message.textContent = message;
this.message.classList.remove("dd-error");
this.message.hidden = false;
}
async upload(file) {
if (file.size > 64 * 1024 * 1024) { this.status("图片文件不能超过 64 MB / File is too large", true); return; }
if (file.size > 64 * 1024 * 1024) { this.status(T("File is too large (max 64 MB)"), true); return; }
this.uploadController?.abort();
const controller=this.uploadController=new AbortController();
const version = this.uploadUiVersion = (this.uploadUiVersion || 0) + 1;
const progress = ({phase, loaded = 0, total = 0}) => {
if (version !== this.uploadUiVersion || this.destroyed) return;
this.uploadIndicator.hidden = false;
if (phase === "upload" && total > 0) {
const percent = Math.max(0, Math.min(100, Math.round(100 * loaded / total)));
this.uploadLabel.textContent = `${T("Uploading image")} ${percent}%`;
this.uploadBar.value = percent;
} else {
this.uploadLabel.textContent = T(phase === "processing" ? "Upload complete, loading image…" : phase === "authenticated" ? "Uploading with authentication…" : "Uploading image…");
this.uploadBar.removeAttribute("value");
}
};
progress({phase: "upload"});
try {
this.status("正在载入原图… / Loading image…");
await this.options.onUpload?.(file);
} catch (error) { this.status(error.message, true); }
this.status(T("Loading image…"));
// Keep the painted mask when a new source replaces the current one,
// the same way adopting a result does. A first upload starts empty.
await this.options.onUpload?.(file, { preserveMask: !!this.image, onProgress: progress, signal:controller.signal });
} catch (error) {
if (version === this.uploadUiVersion && !this.destroyed) this.status(error.message, true);
} finally {
if(this.uploadController===controller)this.uploadController=null;
if (version === this.uploadUiVersion && !this.destroyed) this.uploadIndicator.hidden = true;
}
}
async setImage(url, maskData = "", name = "") {
async setImage(url, maskData = "", name = "", options = {}) {
this.stroke = null; this.pointerId = null;
const version = ++this.loadVersion;
const wasReady=this.ready;
this.ready = false;
try {
const [image, loadedMask] = await Promise.all([loadImage(url), maskData ? loadImage(maskData) : Promise.resolve(null)]);
const [image, loadedMask] = await Promise.all([loadImage(url,options), maskData ? loadImage(maskData) : Promise.resolve(null)]);
if (version !== this.loadVersion || this.destroyed) return;
if (image.width * image.height > 32 * 1024 * 1024 || Math.max(image.width, image.height) > 16384) {
throw new Error("图像超过 3200 万像素或单边 16384px,请先缩小 / Image is too large");
if (image.width * image.height > 32_000_000 || Math.max(image.width, image.height) > 16384) {
throw new Error(T("Image is too large; keep it under 32 MP and 16384 px per side"));
}
this.image = image;
for (const canvas of [this.mask, this.baseMask]) { canvas.width = image.width; canvas.height = image.height; }
@@ -219,7 +305,7 @@ export class InlineMaskEditor {
if (version !== this.loadVersion || this.destroyed) return;
this.baseMask.getContext("2d").drawImage(this.mask, 0, 0);
this.sizeLabel.textContent = `${image.width} × ${image.height}`;
this.filename.textContent = name || "内嵌原图 / Embedded image";
this.filename.textContent = name || T("Embedded image");
this.filename.title = this.filename.textContent;
this.empty.hidden = true;
this.ready = true;
@@ -227,13 +313,31 @@ export class InlineMaskEditor {
this.lastMask = maskData;
this.fit();
this.updateButtons();
this.status("橙色为绘制蒙版 · Alt 临时擦除 · Ctrl+滚轮调笔径");
this.options.onImage?.(image);
this.status(T("Orange is the painted mask · Alt = temporary erase · Ctrl+wheel = brush size"));
} catch (error) {
if (version === this.loadVersion && !this.destroyed) this.status(error.message, true);
if (version === this.loadVersion && !this.destroyed) {this.ready=!!this.image && wasReady;this.updateButtons();this.schedule();this.status(error.message, true);}
throw error;
}
}
async replaceSource(url, name = "", options = {}) {
const version = ++this.loadVersion;
const image = await loadImage(url,options);
if (version !== this.loadVersion || this.destroyed) return false;
// A different size cannot keep the painted mask; report it so the caller
// can fall back to a fresh load instead of failing the upload.
if (image.width !== this.mask.width || image.height !== this.mask.height) return false;
// 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();
return true;
}
importMask(ctx, image) {
const scratch = makeCanvas();
scratch.width = this.mask.width; scratch.height = this.mask.height;
@@ -253,14 +357,19 @@ export class InlineMaskEditor {
}
setTool(tool) {
if (this.colorPaint?.active) { this.colorPaint.active = false; this.colorPaint.sync(); }
this.tool = tool;
if (this.stage) this.stage.classList.toggle("dd-pan", tool === "pan");
this.updateButtons();
}
updateButtons() {
this.keepButton?.classList.toggle("dd-active", !!this.keepFit);
this.keepButton?.setAttribute("aria-pressed", String(!!this.keepFit));
if (this.downloadButton) this.downloadButton.disabled = !this.image;
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);
const active = !this.colorPaint?.active && name === (this.altErase ? "erase" : this.tool);
button.classList.toggle("dd-active", active);
button.setAttribute("aria-pressed", String(active));
}
@@ -274,6 +383,11 @@ export class InlineMaskEditor {
dimensions() {
const r = this.canvas.getBoundingClientRect();
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;
}
this.viewport = { width: r.width, height: r.height };
const scale = this.image ? Math.min(r.width / this.image.width, r.height / this.image.height) * this.zoom : 1;
return { width: r.width, height: r.height, scale, x: (r.width - (this.image?.width || 0) * scale) / 2 + this.pan.x, y: (r.height - (this.image?.height || 0) * scale) / 2 + this.pan.y };
}
@@ -285,6 +399,7 @@ export class InlineMaskEditor {
}
pointerDown(event) {
if (event.button === 2) { event.preventDefault(); event.stopPropagation(); return; }
if (!this.ready || this.pointerId != null) return;
event.preventDefault(); event.stopPropagation(); this.root.focus({ preventScroll: true });
this.altErase = !!event.altKey; this.updateButtons();
@@ -296,14 +411,16 @@ export class InlineMaskEditor {
return;
}
if (this.view === "image") { this.view = "overlay"; this.updateButtons(); }
this.stroke = { type: event.button === 2 || event.altKey ? "erase" : this.tool, size: this.brushSize, hardness: this.hardness, opacity: this.brushOpacity, points: [this.cursor] };
this.stroke = { type: event.altKey ? "erase" : this.tool, size: this.brushSize, hardness: this.hardness, opacity: this.brushOpacity, points: [this.cursor] };
this.updateButtons();
if (["brush", "erase"].includes(this.stroke.type)) this.paintSegment(this.mask.getContext("2d"), this.stroke, this.cursor, this.cursor);
this.schedule();
}
pointerMove(event) {
if (!this.ready) return;
if (this.altErase !== !!event.altKey) { this.altErase = !!event.altKey; this.updateButtons(); }
const eraseModifier=!!event.altKey && !this.colorPaint?.active;
if (this.altErase !== eraseModifier) { this.altErase = eraseModifier; this.updateButtons(); }
this.cursor = this.point(event);
if (event.pointerId === this.pointerId) {
event.preventDefault(); event.stopPropagation();
@@ -313,7 +430,7 @@ export class InlineMaskEditor {
} else if (this.stroke) {
const events = event.getCoalescedEvents?.() || [event];
if (["brush", "erase"].includes(this.stroke.type)) {
const type = event.altKey || (event.buttons & 2) ? "erase" : this.tool;
const type = event.altKey ? "erase" : this.tool;
if (["brush", "erase"].includes(type) && type !== this.stroke.type) {
// Keep separate commands when Alt is pressed/released mid-stroke.
const pivot = this.stroke.points.at(-1);
@@ -347,11 +464,15 @@ export class InlineMaskEditor {
this.drag = null;
const id = this.pointerId; this.pointerId = null;
if (this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id);
this.updateButtons();
this.schedule();
}
paintSegment(ctx, command, a, b) {
if (ctx.canvas === this.mask) this.maskRevision++;
ctx.save();
const bounds = this.options.paintBounds?.();
if (bounds) { ctx.beginPath(); ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height); ctx.clip(); }
ctx.globalCompositeOperation = command.type === "erase" ? "destination-out" : "source-over";
ctx.globalAlpha = command.opacity / 100;
if (command.hardness >= 99 && a.p === 1 && b.p === 1) {
@@ -378,6 +499,9 @@ export class InlineMaskEditor {
}
drawCommand(ctx, command) {
ctx.save();
const bounds = this.options.paintBounds?.();
if (bounds) { ctx.beginPath(); ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height); ctx.clip(); }
const w = this.mask.width, h = this.mask.height;
if (["brush", "erase"].includes(command.type)) {
this.paintSegment(ctx, command, command.points[0], command.points[0]);
@@ -386,7 +510,14 @@ export class InlineMaskEditor {
else if (command.type === "fill") { ctx.save(); ctx.fillStyle = "white"; ctx.fillRect(0, 0, w, h); ctx.restore(); }
else if (command.type === "invert") {
ctx.save(); ctx.globalCompositeOperation = "xor"; ctx.fillStyle = "white"; ctx.fillRect(0, 0, w, h); ctx.restore();
} else if (command.type === "import") this.importMask(ctx, command.image);
} else if (command.type === "import") {
if (!bounds) this.importMask(ctx, command.image);
else {
const imported = makeCanvas(); imported.width = w; imported.height = h;
this.importMask(imported.getContext("2d"), command.image);
ctx.clearRect(0, 0, w, h); ctx.drawImage(imported, 0, 0); imported.width = imported.height = 0;
}
}
else if (["rect", "lasso"].includes(command.type)) {
const a = command.points[0], b = command.points.at(-1);
ctx.save(); ctx.fillStyle = "white"; ctx.globalAlpha = command.opacity / 100;
@@ -394,12 +525,18 @@ export class InlineMaskEditor {
else { ctx.beginPath(); ctx.moveTo(a.x, a.y); for (const p of command.points) ctx.lineTo(p.x, p.y); ctx.closePath(); ctx.fill(); }
ctx.restore();
}
ctx.restore();
}
remember(command) {
this.history.push(command); this.future = [];
// Keep commands rather than full-resolution pixel snapshots; memory stays small.
if (this.history.length > 500) this.drawCommand(this.baseMask.getContext("2d"), this.history.shift());
let bitmapBytes = this.history.reduce((total, item) => total + (item.image ? item.image.width * item.image.height * 4 : 0), 0);
while (this.history.length > 500 || (this.history.length > 1 && bitmapBytes > 64 * 1024 * 1024)) {
const first = this.history.shift();
this.drawCommand(this.baseMask.getContext("2d"), first);
bitmapBytes -= first.image ? first.image.width * first.image.height * 4 : 0;
}
this.updateButtons();
}
@@ -419,6 +556,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();
@@ -459,8 +597,29 @@ export class InlineMaskEditor {
const link = el("a"); link.download = "adachi-mask.png"; link.href = this.flush(); link.click();
}
downloadSource() {
if (!this.image) return;
const out = makeCanvas();
out.width = this.image.naturalWidth || this.image.width;
out.height = this.image.naturalHeight || this.image.height;
out.getContext("2d").drawImage(this.image, 0, 0);
const label = String(this.filename.textContent || "").split(/[\\/]/).pop().replace(/\.[^.]+$/, "");
const link = el("a");
link.download = `${label || "adachi-source"}.png`;
link.href = out.toDataURL("image/png");
link.click();
out.width = out.height = 0;
}
fit() { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }
setKeepFit(enabled, notify = true) {
// Pin freezes the view exactly as drawn; it must not re-fit the image.
this.keepFit = !!enabled;
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"));
@@ -469,6 +628,13 @@ export class InlineMaskEditor {
wheel(event) {
if (!this.ready) return;
event.preventDefault(); event.stopPropagation();
if (event.shiftKey) {
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1);
this.hardnessInput.value = Math.max(0, Math.min(100, this.hardness + (delta < 0 ? 5 : delta > 0 ? -5 : 0)));
this.hardnessInput.dispatchEvent(new Event("input"));
this.cursor = this.point(event); this.schedule(); return;
}
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) {
@@ -503,27 +669,38 @@ export class InlineMaskEditor {
schedule() { if (!this.frame && !this.destroyed) this.frame = requestAnimationFrame(() => { this.frame = 0; this.render(); }); }
render() {
const view = this.options.displayView?.() || this.view;
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, view, this.overlayOpacity, this.options.sceneRevision?.() || 0].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 (view !== "mask") { if (!this.options.drawSource?.(ctx)) ctx.drawImage(this.image, 0, 0); }
else { ctx.fillStyle = "black"; ctx.fillRect(0, 0, this.image.width, this.image.height); }
if (view !== "image" && (view === "mask" || this.options.showMask?.() !== false)) {
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 (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 = 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);
this.options.drawOverlay?.(ctx, d);
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]);
const a = this.stroke.points[0], b = this.stroke.points.at(-1);
@@ -531,18 +708,26 @@ export class InlineMaskEditor {
else { ctx.beginPath(); ctx.moveTo(a.x, a.y); for (const p of this.stroke.points) ctx.lineTo(p.x, p.y); ctx.stroke(); }
ctx.restore();
}
if (this.cursor && (this.altErase || ["brush", "erase"].includes(this.tool)) && !this.drag) {
if (this.cursor && (this.altErase || ["brush", "erase"].includes(this.tool)) && !this.drag && this.options.showCursor?.() !== false) {
const erasing = this.altErase || this.tool === "erase" || this.stroke?.type === "erase";
ctx.save();
if (erasing) ctx.setLineDash([4 / d.scale, 3 / d.scale]);
ctx.beginPath(); ctx.arc(this.cursor.x, this.cursor.y, this.brushSize * this.cursor.p / 2, 0, Math.PI * 2);
ctx.strokeStyle = "#15191f"; ctx.lineWidth = 3 / d.scale; ctx.stroke(); ctx.strokeStyle = "white"; ctx.lineWidth = 1 / d.scale; ctx.stroke();
ctx.strokeStyle = "#15191f"; ctx.lineWidth = 3 / d.scale; ctx.stroke(); ctx.strokeStyle = erasing ? "#ffd0d7" : "white"; ctx.lineWidth = 1 / d.scale; ctx.stroke();
ctx.restore();
}
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
}
destroy() {
this.uploadController?.abort();++this.uploadUiVersion;
this.destroyed = true; ++this.loadVersion;
cancelAnimationFrame(this.frame); this.observer.disconnect();
window.removeEventListener("wheel", this.captureWheel, true);
for (const canvas of [this.mask, this.baseMask, this.overlay, this.canvas]) canvas.width = canvas.height = 0;
this.history = []; this.future = []; this.image = null; this.root.remove();
this.stopWheelCapture();
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.scene, this.canvas]) canvas.width = canvas.height = 0;
this.history = []; this.future = []; this.image = null; this.controls?.remove(); this.root.remove();
}
}
+35
View File
@@ -0,0 +1,35 @@
const disabled = node => node.mode === 2 || node.mode === 4;
const inputLink = (graph, input) => input?.link == null ? null
: graph.getLink?.(input.link) ?? graph.links?.[input.link];
/** Follow routing nodes without treating ordinary processing nodes as routers. */
export function branchIsEnabled(node, name) {
const graph = node.graph;
if (!graph) return false;
const seen = new Set();
function visit(source, slot = 0) {
if (!source || seen.has(source) || disabled(source)) return false;
seen.add(source);
const sourceGraph = source.graph || graph;
const follow = link => link ? visit(sourceGraph.getNodeById(link.origin_id), link.origin_slot) : false;
// KJ Get nodes have no physical inputs. Follow their actual setter so
// its ignored state is checked too; getInputLink alone skips the Set.
if (source.isVirtualNode && typeof source.findSetter === "function") {
return visit(source.findSetter(sourceGraph), slot);
}
if (source.type === "Reroute" || source.type === "SetNode") {
return follow(inputLink(sourceGraph, source.inputs?.[slot] || source.inputs?.[0]));
}
if (source.isVirtualNode && typeof source.resolveVirtualOutput === "function") {
const resolved = source.resolveVirtualOutput(slot);
if (resolved?.node) return visit(resolved.node, resolved.slot ?? 0);
}
if (source.isVirtualNode && typeof source.getInputLink === "function") {
return follow(source.getInputLink(slot));
}
return true;
}
const link = inputLink(graph, node.inputs?.find(input => input.name === name));
return link ? visit(graph.getNodeById(link.origin_id), link.origin_slot) : false;
}
+41
View File
@@ -0,0 +1,41 @@
import { app } from "../../scripts/app.js";
import { branchIsEnabled } from "./exclusive_routes.js?v=0.5.6";
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 = 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);
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;
}
}
+157
View File
@@ -0,0 +1,157 @@
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")));
const tools = el("div", "dd-gp-toolbar-actions"); toolbar.append(tools);
this.toggleDetail = this.button(tools, "", () => {
app.canvas?.emitBeforeChange?.();
this.node.properties.adachi_group_detail_hidden = !this.node.properties.adachi_group_detail_hidden;
this.updateDetail();
app.canvas?.emitAfterChange?.();
this.node.graph?.setDirtyCanvas(true, true);
});
this.add = this.button(tools, 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 = this.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 = this.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.updateDetail(); this.refresh(true);
}
updateDetail() {
const hidden = !!this.node.properties.adachi_group_detail_hidden;
this.detail.hidden = hidden;
this.body.classList.toggle("dd-gp-collapsed", hidden);
this.toggleDetail.textContent = hidden ? "▶" : "◀";
this.toggleDetail.title = T(hidden ? "Show group details" : "Hide group details");
this.toggleDetail.setAttribute("aria-label", this.toggleDetail.title);
this.toggleDetail.setAttribute("aria-expanded", String(!hidden));
}
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.updateDetail(); 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";
},
});
+389
View File
@@ -0,0 +1,389 @@
// Locale-aware labels for this plugin's own canvas UI.
//
// The English string in the call site is the default and the dictionary key, so any
// missing translation falls back to English. ComfyUI's locales/ files only cover node
// definitions (display name, category, sockets, tooltips); the custom panel and canvas
// buttons are drawn by this plugin, so they translate here by reading Comfy.Locale.
const ZH = {
"Image upload timed out; check the connection and try again": "图片上传超时,请检查连接后重试",
"Load a source image first": "请先载入原图",
"Finish the current edit first": "请先结束当前编辑",
"The source image node was removed": "原图节点已移除",
"The image changed during restoration; try again": "图片在恢复过程中发生变化,请重试",
"The saved mask state is invalid": "历史蒙版状态无效",
"Source image": "原图",
"Edited source": "编辑中的原图",
"Processing the source image; please wait": "正在处理原图,请稍候",
"The source image history entry no longer exists": "原图历史不存在",
// Workbench panels, execution status and accessibility labels.
"Reading the current workflow…": "正在读取当前工作流…",
"Switch theme": "切换主题",
"Check connections": "检查连接",
"Return to workflow": "返回工作流",
"Edit mask": "编辑蒙版",
"Slide to compare": "滑动对比",
"Show before": "查看前图",
"Show after": "查看后图",
"Adopt": "采用",
"Use this result as the next source image and keep the mask; restore earlier images below": "采用当前结果作为下一轮原图,保留蒙版;下方可回退或选择之前的原图",
"Selected source image or generated result": "当前选择的前图或重绘结果",
"↶ Undo": "↶ 回退",
"Initial source": "初始原图",
"Choose an adopted source image": "选择已采用原图",
"Restore the previous source image, mask and crop": "回退到之前的原图,同时恢复蒙版和裁切状态",
"Restore the initial source image of this session": "恢复本次工作流会话中的初始原图",
"Choose an earlier adopted or uploaded source image": "选择之前采用或上传的原图",
"Current stage progress": "当前执行阶段进度",
"Actual progress from the current node; stages without step counts show an indeterminate bar": "显示当前节点的真实进度;没有步数的阶段显示等待动画",
"Workflow settings": "工作流设置",
"Generate": "生成",
"Generating": "生成中",
"Generation complete": "生成完成",
"Controls are not ready; reopen the workbench shortly": "控件尚未初始化,请稍后重新打开",
"This workflow needs Inline Mask, Adachi Inpaint Workbench and Image Compare nodes": "当前工作流需要内联蒙版、安达重绘工作台和图像对比节点",
"Source when opened": "打开时原图",
"Adopted result": "采用重绘结果",
"Uploaded source": "上传原图",
"Generation stopped": "生成已停止",
"Generating…": "生成中…",
"Workflow connections": "工作流连接",
"Multiple candidates found; select the nodes to use": "存在多个候选,请选择本次使用的节点",
"Inpaint node": "重绘节点",
"Image editor": "图像编辑",
"Image comparison": "图像对比",
"No preset controller": "不使用预设",
"Use these connections": "使用此连接",
"Select nodes from this workflow; this inpaint node remembers your choices": "选择当前工作流中的节点;同一重绘节点会记住本次选择",
"The workflow changed": "工作流已切换",
"Select the nodes to use in Workflow connections first": "请先在工作流连接中选择本次使用的节点",
"Upload a source image first": "请先上传原图",
"Checking in the background…": "后台检查中…",
"Workflow validation failed": "工作流检查未通过",
"Ready to generate": "检查通过 · 可以生成",
"Checking…": "检查中…",
"Queued for generation": "已进入生成队列",
"Generation failed; check the ComfyUI error log": "生成失败,请查看 ComfyUI 错误信息",
"Switch to light theme": "切换为白色主题",
"Switch to dark theme": "切换为黑色主题",
"Current workflow": "当前工作流",
"Reading workflow connections": "正在读取工作流连接",
"Actual sampler connections": "实际采样器连线",
"Main model": "主模型",
"Text encoder": "文本编码器",
"The group preset no longer exists": "分组预设不存在",
"Reading workflow connections; please wait": "正在读取工作流连接,请稍候",
"Some model lists are unavailable; existing workflow selections remain usable": "部分模型列表不可用,可保留工作流中的现有选择",
"Multiple group preset controllers found; choose one in Workflow connections.": "存在多个分组预设控制器,请在工作流连接中选择。",
"No enabled Group Bypass Presets node; using the current workflow settings.": "未启用组忽略预设节点,当前使用原工作流设置。",
"Main model and LoRA": "主模型与 LoRA",
"This profile uses the model from the workflow": "当前方案的模型由原工作流控制",
"Inherit workflow LoRAs": "沿用工作流 LoRA",
"Keep existing LoRA nodes and both CLIP branches; uncheck to use the custom list below. An empty custom list disables recognized LoRAs.": "保留原有 LoRA 节点及正负 CLIP 分支;取消后使用下方自定义列表,空列表即禁用已识别的 LoRA",
"No active LoRAs in the original workflow": "原工作流没有启用的 LoRA",
"No LoRA": "不使用 LoRA",
"None": "不使用",
"Remove this LoRA": "移除此 LoRA",
"+ Add LoRA": "+ 添加 LoRA",
"Use the workflow Refiner (must be compatible with the main model)": "使用工作流中的 Refiner(需与主模型兼容)",
"Positive prompt": "正面提示词",
"Negative prompt": "负面提示词",
"Fit comparison image": "适应对比图片",
"Controlled by the original workflow": "由原工作流控制",
"Reading connections…": "正在读取连接…",
"Encoders are identified from the execution graph; unrecognized conditioning stays controlled by the original workflow": "通过实际执行图查找编码器;无法明确识别的条件保留原工作流",
"Inherit upstream dynamic text; type to override": "沿用上游动态文本;输入后覆盖",
"Text comes from upstream; typing creates an override.": "文本来自上游;主动输入才覆盖。",
"Changes affect only this encoder; downstream conditioning is preserved.": "修改仅影响所选编码器,保留下游条件处理。",
"Loading main model": "加载主模型",
"Loading text encoder": "加载文本编码器",
"Loading VAE": "加载 VAE",
"Encoding prompts": "编码提示词",
"Loading LoRA": "加载 LoRA",
"Reading source image and mask": "读取原图与蒙版",
"Preparing inpaint sampling": "重绘采样准备",
"KSampler": "K 采样器",
"VAE decode": "VAE 解码",
"VAE encode": "VAE 编码",
"Preparing comparison images": "准备对比图片",
"Queued…": "排队中…",
"Executing workflow": "正在执行工作流",
"Reconnected; waiting for execution progress…": "已重连,等待执行进度…",
"Connection lost; recovering execution progress…": "连接中断,正在恢复进度…",
"Starting execution…": "开始执行…",
"Inpaint sampling (KSampler)": "重绘采样(K 采样器)",
"Resize canvas and settings": "调整画布与配置宽度",
"Drag to resize the canvas and settings; double-click to reset, or use arrow keys for fine adjustments": "拖动调整画布与配置宽度;双击恢复,方向键微调",
"The workbench backend is not loaded. Stop and restart ComfyUI, then check connections again; refreshing the browser is not enough.": "工作台后端尚未加载:请在 ComfyUI 启动器中停止后重新启动服务,再点击检查连接(仅刷新网页不够)",
"Submission timed out. Check the task queue before retrying to avoid duplicate generations.": "提交超时,请先检查任务队列,避免重复生成",
"Connection check timed out; check your network and retry": "连接检查超时,请检查网络后重试",
"The workflow changed; reopen the workbench": "工作流已切换,请重新打开工作台",
"Workflow connections could not be resolved; check bypass states, Get/Set and broadcast connections": "工作流连线无法解析,请检查忽略状态、Get/Set 或广播连线",
"The workflow changed; check connections again": "工作流已切换,请重新检查",
"The selected image, inpaint or comparison node is missing from the execution graph": "选中的图像、重绘或对比节点未出现在执行图中",
"The selected inpaint node is missing from the execution graph": "所选重绘节点未出现在执行图中",
"Connect a main model before adding LoRAs": "请连接主模型后再添加 LoRA",
"Open Adachi Workbench": "打开安达工作台",
"Text encoder {index}": "文本编码器 {index}",
"Model list request failed ({status})": "模型列表加载失败({status})",
"Missing {count} model files": "缺少 {count} 份模型文件",
"{label}: {value}": "{label}:{value}",
"LoRA {index} strength": "LoRA {index} 强度",
"LoRA {index}": "LoRA {index}",
"{label} · {count} encoders": "{label} · {count} 个编码器",
"Actual sampler connections · {node} #{id} · {field}. {description}": "实际采样器连线 · {node} #{id} · {field}。{description}",
"Restore {label} from the workflow": "恢复工作流中的{label}",
"Reading cache · {count} nodes": "读取缓存 · {count} 个节点",
"Settings panel {width} pixels wide": "右侧配置 {width} 像素",
"Invalid workbench response ({status}); check your proxy settings or restart ComfyUI": "工作台服务响应无效({status}),请检查代理配置或重启 ComfyUI",
"{label} not found: {value}": "未找到{label}:{value}",
"The selected parameter node is missing from the execution graph #{id}": "所选参数节点未出现在执行图中 #{id}",
"Comparison image loading progress": "对比图片加载进度",
"Waiting for the generated image…": "等待生成结果,完成后自动找回…",
"Generation failed; check the ComfyUI task history": "生成失败,请查看 ComfyUI 任务历史",
"Loading comparison images": "正在加载对比图片",
"Retrying comparison images": "正在重试对比图片",
"Decoding comparison images…": "下载完成,正在解码图片…",
"Waiting to retry comparison images": "等待自动重试图片",
"Image loading failed; retrying automatically. You can also click reload.": "图片加载失败,正在自动重试;也可点击刷新。",
"Show group details": "展开分组详情",
"Hide group details": "收起分组详情",
"Reload comparison images": "重新加载对比图片",
"Image upload progress": "图片上传进度",
"Uploading image": "正在上传图片",
"Downloading result…": "正在下载结果…",
"Uploading result…": "正在上传结果…",
"Preparing result…": "正在准备结果…",
"Uploading image…": "正在上传图片…",
"Upload complete, loading image…": "上传完成,正在加载图片…",
"Uploading with authentication…": "正在通过身份验证上传…",
"Network error while uploading image": "上传图片时网络连接失败",
"Upload was cancelled": "上传已取消",
"Invalid upload response": "上传响应无效",
"Download the latest generated image": "下载最新生成的图片",
"Mask: paint the full image. Crop & Blend: select a region, inpaint it, then adopt and blend the result.": "普通蒙版:在整图涂抹。裁切融合:选定局部、重绘,再从图像对比采用结果并融合图层。",
"Crop resolution presets. Changing size releases the current selection; left-click the image to place a new region. Inpaint dimensions follow when selected.": "裁切分辨率预设。切换尺寸会取消当前选定,再左键点击图片定位新区域;选定后自动填写 Inpaint 宽高。",
"Swap width and height, release the current selection, then left-click to place the new region.": "交换宽高并取消当前选定,再左键点击图片选择新区域。",
"Edit layer blending: brush keeps the new result; Alt restores original pixels. Hold right-click to preview the original, release to return.": "编辑融合蒙版:画笔保留重绘结果,Alt 擦回原图。按住右键查看原图,松开恢复当前工作区。",
"Merge the visible result and blend mask into the source image. After merging, right-click releases the selected region.": "将当前可见结果和融合蒙版合并到原图。合并后,右键恢复为取消选区。",
"Undo the last stroke in the active repaint or blend mask (Ctrl+Z). Redo with Ctrl+Shift+Z.": "撤销当前重绘蒙版或融合蒙版的上一笔(Ctrl+Z);Ctrl+Shift+Z 可重做。",
"Custom crop dimension in 64-pixel steps. Changing it releases the selected region.": "自定义裁切宽高,按 64 像素递增;修改尺寸会取消当前选定。",
"Paint inside the selected region. Right-click releases the selection; left-click places a new one. Alt: erase. Ctrl+wheel: brush size; Shift+wheel: hardness.": "在选区内涂蒙版。右键取消选定,再左键选择新区域;Alt 擦除;Ctrl+滚轮调笔径,Shift+滚轮调硬度。",
"Select a crop region before adopting a result": "请先选择裁切区域再采用结果",
"Finish editing before adopting a result": "请结束当前编辑后再采用结果",
"This result belongs to another crop; generate a result for the current region": "该结果来自其他选区,请为当前选区重新生成",
"Result dimensions must match the selected crop": "结果宽高必须与当前裁切区域一致",
"The crop changed while adopting the result": "采用过程中选区已改变,请重新生成结果",
"The result was not applied; the crop or layer state changed": "结果未应用,选区或图层状态已改变",
"The result preview is still loading": "结果预览仍在加载中",
"Select an inline mask to adopt into": "请选择要回填的内联蒙版",
"Painting tools and mask settings": "绘图工具与蒙版设置",
"Browser draft storage was full. The source was saved as an input file; the image, masks and layers are preserved.": "浏览器草稿空间不足,已将原图保存为图片文件;图像、蒙版和图层均已保留。",
"Editing mode": "编辑模式",
"Crop & Blend": "裁切融合",
"Crop size": "裁切尺寸",
"Custom": "自定义",
"Swap crop dimensions": "交换宽高",
"Choose another region": "重新选区(自动合并当前图层)",
"Repaint mask": "重绘蒙版",
"Blend layers: brush keeps new pixels; Alt restores original": "图层融合:画笔保留新图,Alt 擦回原图",
"Merge the visible layers": "合并当前可见图层",
"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": "选择重绘结果",
"Connect an original image and a result, then run the workflow": "连接原图与重绘结果,然后运行工作流",
"Adopt into": "回填到",
"Target inline editor": "接收结果的内联蒙版",
"Adopt this result": "采用此结果",
"Move to compare · wheel to zoom · middle-drag to pan": "鼠标移动对比 · 滚轮缩放 · 中键拖动",
"Select an inline mask": "选择内联蒙版",
"Adopting…": "采用中…",
"Setting the result as the new source image…": "正在将结果设为新原图…",
"Adopted: back in the inline mask, keep painting to inpaint again": "已采用:回到内联蒙版,可继续涂抹重绘",
"Inline Mask": "内联蒙版",
"No inline mask node is available to receive the result": "找不到接收结果的内联蒙版节点",
"Result preview expired; run the workflow again": "结果预览已过期,请重新运行工作流",
"Result image exceeds 64 MiB; downscale it first": "结果图片超过 64 MiB,请先缩小",
// editor.js
"Cannot load image": "无法读取图像",
"INLINE MASK": "内联蒙版 · INLINE MASK",
"Load image": "载入原图",
"Upload an image, or drop / paste one": "上传图片,也可拖放或粘贴",
"Drop an image onto the canvas": "拖放图片到画布",
"Brush": "画笔",
"Brush (B)": "画笔 (B)",
"Eraser": "橡皮",
"Eraser (E); hold Alt for temporary erase": "橡皮 (E),按住 Alt 临时擦除",
"Rectangle": "矩形",
"Rectangle (R)": "矩形 (R)",
"Lasso": "套索",
"Freehand lasso (L)": "自由套索 (L)",
"Pan": "移动",
"Pan (H), or hold Space": "移动 (H),或按住空格",
"Brush size": "笔径",
"Paint image pixels; Alt-click picks a color; Ctrl+wheel changes brush size": "画笔:Alt 加左键吸取颜色,Ctrl 加滚轮调整笔径",
"Brush color": "画笔颜色",
"Undo color painting": "撤销画笔",
"Redo color painting": "重做画笔",
"Paint layer dimensions do not match the source image": "画笔图层尺寸与原图不一致",
"Paint layer could not be saved as an input file; embedded data was retained": "无法将画笔图层保存到输入目录,已保留嵌入数据",
"The paint layer is unavailable; update and restart ComfyUI": "画笔图层不可用,请更新并重启 ComfyUI",
"Hardness": "硬度",
"Brush opacity": "笔触",
"Overlay": "叠加",
"Drop an image here": "把原图拖到这里",
"Paint the areas to repaint directly in this node": "直接在节点中涂抹需要重绘的区域",
"Undo": "撤销",
"Undo (Ctrl+Z)": "撤销 (Ctrl+Z)",
"Redo": "重做",
"Redo (Ctrl+Shift+Z)": "重做 (Ctrl+Shift+Z)",
"Invert": "反转",
"Invert the painted mask": "反转绘制的蒙版",
"Fill": "全选",
"Fill the whole image with mask": "重绘整张图",
"Clear": "清空",
"Clear the mask": "清空蒙版",
"Import mask": "导入蒙版",
"White repaints, black preserves": "白色重绘,黑色保护",
"Export": "导出",
"Export the mask PNG at full resolution": "导出原始分辨率黑白蒙版",
"Image": "原图",
"Mask": "蒙版",
"Fit": "适应",
"Keep": "保持",
"Pin the current view": "固定当前视图",
"Download the source image": "下载原图",
"Upload": "上传",
"Fit the image to the canvas": "适应画布",
"File is too large (max 64 MB)": "图片文件不能超过 64 MB",
"Loading image…": "正在载入原图…",
"Embedded image": "内嵌原图",
"Orange is the painted mask · Alt = temporary erase · Ctrl+wheel = brush size": "橙色为绘制蒙版 · Alt 临时擦除 · Ctrl+滚轮调笔径",
"Image is too large; keep it under 32 MP and 16384 px per side": "图像超过 3200 万像素或单边 16384px,请先缩小",
// inline_inpaint.js
"The inline mask node was removed": "内联蒙版节点已移除",
"Adopted the result as the new source image; the mask and undo history were preserved": "已采用重绘结果作为新原图,蒙版与撤销记录已保留",
"The new image has a different size, so the mask was reset": "新图尺寸与原图不同,蒙版已重置",
"Wait for the current image to finish loading": "请等待当前图片加载完成",
"Drag to resize canvas height": "上下拖动调整画布高度",
"Resize canvas height": "调整画布高度",
"Upload failed": "上传失败",
// inpaint_node.js
"Connect an inline mask to show the painting tools": "连接内联蒙版后显示画笔工具",
"The painting tools live in the other inpaint node connected to this image": "画笔工具位于连接此原图的另一个重绘节点",
// inpaint_panel.js
"Adachi Inpaint Workbench": "安达重绘工作台",
"Controlled WebUI inpaint node": "控制的局部重绘节点",
"Connect a WebUI inpaint node to edit its parameters here": "连接安达重绘工作台后,在这里调整参数",
"Mask mode": "重绘区域",
"Fill content": "重绘参考内容",
"Sampling area": "重绘画布",
"Sampling resolution": "采样分辨率",
"Use source size": "使用原图宽高",
"Fill width and height from the current source image; aligned to 8 px and clamped to 64-8192 px": "填写当前原图宽高;按 8 的倍数对齐,并限制在 64–8192 像素",
"Width": "宽度",
"Height": "高度",
"Denoise": "重绘幅度",
"Sampler": "采样器",
"Scheduler": "调度器",
"Steps": "采样步数",
"CFG scale": "提示词引导 CFG",
"Seed": "随机种子",
"After generate": "生成后种子",
"Mask, batch and advanced settings": "蒙版、批量与高级设置",
"Context padding": "蒙版外扩边距",
"Mask blur": "蒙版模糊",
"Mask grow / shrink": "蒙版扩张 / 收缩",
"Batch size": "每张生成数量",
"Resize mode": "缩放方式",
"Use dedicated inpaint model": "使用专用 Inpaint 模型",
"Extra edge feathering": "额外羽化边缘",
"Feather strength": "额外羽化强度",
"Refiner switch ratio": "Refiner 切换比例",
"Preview unavailable; run the workflow again": "预览已过期或无法加载,请重新运行",
"Result left, original right; hover to compare": "左重绘结果、右原图,移动鼠标竖线对比",
"Inline mask painting canvas; orange areas are repainted": "直接涂抹蒙版;橙色区域将被重绘",
"Embed the source image in the workflow; no separate file needed when sharing": "原图嵌入工作流 · 分享时无需另传原图",
"Alt = temporary erase · Ctrl+wheel = brush size · wheel = zoom · Space = pan": "Alt 临时擦除 · Ctrl+滚轮调笔径 · 滚轮缩放 / 空格拖动",
// enum values
"enum:Inside mask": "蒙版内",
"enum:Outside mask": "蒙版外",
"enum:Fill": "填充",
"enum:Original pixels": "原图像素",
"enum:Latent noise": "潜空间噪声",
"enum:Empty latent": "空白潜空间",
"enum:Whole image": "整图重绘",
"enum:Masked area only": "仅蒙版区域",
"enum:Stretch": "拉伸",
"enum:Center crop": "居中裁剪",
"enum:Aspect fill": "保比例填充",
"enum:Fixed": "固定种子",
"enum:Increment": "生成后递增",
"enum:Decrement": "生成后递减",
"enum:Randomize": "生成后随机",
};
const DICTS = { zh: ZH };
function readLocale() {
// Modern frontends expose an ES-module namespace at comfyAPI.app, while
// earlier builds exposed the app directly. Never infer the UI language from
// the browser: ComfyUI's selected locale is the user's explicit preference.
const api = globalThis.comfyAPI;
for (const app of [api?.app?.app, api?.app, globalThis.app]) {
try {
const value = app?.ui?.settings?.getSettingValue?.("Comfy.Locale");
if (value) return String(value);
} catch (_) { /* settings may be unavailable before the app boots */ }
}
return "en";
}
export function localeDict() {
const base = readLocale().split(/[-_]/)[0].toLowerCase();
return Object.hasOwn(DICTS, base) ? DICTS[base] : null;
}
// English is the fallback for every locale. The optional string context retains
// enum disambiguation; an object supplies named values without concatenating
// translated fragments. Values remain text and are never interpreted as HTML.
export function T(text, contextOrParams, params = {}) {
const context = typeof contextOrParams === "string" ? contextOrParams : null;
const values = contextOrParams && typeof contextOrParams === "object" ? contextOrParams : params;
const dict = localeDict();
const key = context ? `${context}:${text}` : text;
const translated = dict && Object.hasOwn(dict, key) ? dict[key] : text;
return translated.replace(/\{([A-Za-z][A-Za-z0-9_]*)\}/g, (placeholder, name) =>
Object.hasOwn(values, name) ? String(values[name]) : placeholder);
}
+24
View File
@@ -0,0 +1,24 @@
import {T} from './i18n.js';
// Retry only freshly uploaded same-origin view URLs. Local files, data URLs and
// restored inputs keep their original behavior. Each retry bypasses cached 404s.
export async function loadImage(url,{retry=false,signal,timeoutMs=15000,delays=[300,900]}={}) {
const parsed=new URL(url,location.href);
const canRetry=retry && parsed.origin===location.origin && /\/view$/.test(parsed.pathname);
const attempts=canRetry?delays.length+1:1;
for(let i=0;i<attempts;i++){
signal?.throwIfAborted();
if(i){await new Promise((resolve,reject)=>{const timer=setTimeout(done,delays[i-1]);function done(){signal?.removeEventListener('abort',abort);resolve();}function abort(){clearTimeout(timer);reject(new DOMException('Aborted','AbortError'));}signal?.addEventListener('abort',abort,{once:true});});}
const image=new Image();
try{
await new Promise((resolve,reject)=>{
const finish=fn=>value=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);image.onload=image.onerror=null;fn(value);};
const success=finish(resolve),failure=finish(reject),abort=()=>{image.src='';failure(new DOMException('Aborted','AbortError'));};
const timer=setTimeout(()=>failure(new Error(T('Cannot load image'))),timeoutMs);
image.onload=success;image.onerror=()=>failure(new Error(T('Cannot load image')));signal?.addEventListener('abort',abort,{once:true});
const request=new URL(parsed);if(i)request.searchParams.set('_adachi_retry',String(i));image.src=request.href;
});
return image;
}catch(error){if(error.name==='AbortError'||i===attempts-1)throw error;}
}
}
+709
View File
@@ -0,0 +1,709 @@
import { CROP_PRESETS, cropAt } from "./crop_geometry.js?v=0.6.2";
import { T } from "./i18n.js";
import { syncCropDimensions, syncCurrentDimensions } from "./parameter_bridge.js?v=0.8.2";
import { installResultAdoption } from "./result_adoption.js?v=2.1.0";
const fields = ["mask", "baseMask", "history", "future", "lastMask", "dirty"];
const canvas = (w, h) =>
Object.assign(document.createElement("canvas"), { width: w, height: h });
const load = async (url) => {
const image = new Image();
image.src = url;
await image.decode();
return image;
};
const make = (tag, cls) => {
const e = document.createElement(tag);
e.className = cls;
return e;
};
export class InlineCropController {
constructor(
node,
editor,
{ find, hide, changed, imageUrl, beforeModeChange },
) {
Object.assign(this, { node, editor, changed, imageUrl, beforeModeChange });
this.widgets = Object.fromEntries(
["edit_mode", "crop_state", "blend_data", "layer_image"].map((name) => [
name,
find(name),
]),
);
Object.values(this.widgets).forEach(hide);
this.contexts = {};
this.target = "mask";
this.layer = null;
this.revision = 0;
this.acceptVersion = 0;
this.cropWidth = 1024;
this.cropHeight = 1536;
this.patchCanvas = canvas(1, 1);
this.build();
installResultAdoption(node, this);
// Only user uploads go through editor.upload; internal layer merges and
// adopting an already-sized result call onUpload directly instead.
const upload = editor.upload.bind(editor);
editor.upload = async file => {
const previous = editor.image;
await upload(file);
if (!editor.destroyed && editor.ready && editor.image !== previous && this.mode === "mask") {
syncCurrentDimensions(node);
}
};
const originalFlush = editor.flush.bind(editor);
editor.flush = () => {
if (this.target !== "blend") return originalFlush();
if (!editor.ready || !this.region?.locked)
return this.widgets.blend_data.value;
if (!editor.dirty && !editor.stroke) return this.widgets.blend_data.value;
const r = this.region,
out = canvas(r.width, r.height),
ctx = out.getContext("2d");
ctx.fillStyle = "black";
ctx.fillRect(0, 0, r.width, r.height);
ctx.drawImage(
editor.mask,
r.x,
r.y,
r.width,
r.height,
0,
0,
r.width,
r.height,
);
this.widgets.blend_data.value = out.toDataURL("image/png");
out.width = out.height = 0;
editor.dirty = false;
return this.widgets.blend_data.value;
};
editor.options.sceneRevision = () =>
`${this.mode}:${this.target}:${this.revision}`;
editor.options.drawSource = (ctx) => this.drawSource(ctx, {preview:this.previewOriginal});
editor.options.displayView = () => this.previewOriginal ? "image" : editor.view;
editor.options.showCursor = () => !this.previewOriginal;
editor.options.showMask = () =>
this.mode !== "crop" || (this.target === "mask" && this.region?.locked);
editor.options.drawOverlay = (ctx, d) => this.drawOverlay(ctx, d);
editor.options.paintBounds = () =>
this.mode === "crop" && this.region?.locked ? this.region : undefined;
const down = editor.pointerDown.bind(editor),
move = editor.pointerMove.bind(editor);
editor.pointerDown = (e) => {
if (!editor.ready) return;
if (this.previewOriginal) { e.preventDefault(); e.stopPropagation(); return; }
if (this.busy) {
e.preventDefault();
e.stopPropagation();
return;
}
if (this.mode === "crop" && e.button === 2) {
e.preventDefault();
e.stopPropagation();
editor.root.focus({ preventScroll: true });
// Hold-right preview only makes sense while adjusting the blend layer.
// After adoption the layer is already baked, so right-click reselects
// (reselect flattens any remaining layer first).
if (this.target === "blend" && this.layer && this.region.locked && !editor.stroke && editor.pointerId == null) {
this.previewOriginal = true;
this.originalPreviewPointer = e.pointerId;
editor.canvas.setPointerCapture(e.pointerId);
editor.cursor = null;
this.revision++;
editor.schedule();
} else if (this.region.locked) this.run(() => this.reselect());
return;
}
if (
this.mode === "crop" &&
!this.region?.locked &&
e.button === 0 &&
!editor.space
) {
e.preventDefault();
e.stopPropagation();
editor.root.focus({ preventScroll: true });
const p = editor.point(e),
r = cropAt(
editor.image.width,
editor.image.height,
this.cropWidth,
this.cropHeight,
p.x,
p.y,
);
if (r) this.lock(r);
return;
}
down(e);
};
editor.pointerMove = (e) => {
if (this.previewOriginal) { editor.cursor = null; return; }
if (
this.mode === "crop" &&
editor.ready &&
!this.region?.locked &&
!editor.drag
) {
const p = editor.point(e);
this.hover = cropAt(
editor.image.width,
editor.image.height,
this.cropWidth,
this.cropHeight,
p.x,
p.y,
);
}
move(e);
};
this.finishPreview = event => {
if (event.pointerId !== this.originalPreviewPointer) return;
if (event.type === "pointerup" && (event.buttons & 2)) return;
event.preventDefault(); event.stopPropagation();
this.stopOriginalPreview();
};
for (const name of ["pointerup", "pointercancel"]) {
window.addEventListener(name, this.finishPreview, true);
}
editor.canvas.addEventListener("lostpointercapture", this.finishPreview, true);
this.previewBlur = () => this.stopOriginalPreview();
window.addEventListener("blur", this.previewBlur);
editor.canvas.addEventListener("pointerleave", () => {
this.hover = null;
editor.schedule();
});
editor.root.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.mode === "crop" && !editor.stroke) {
e.preventDefault();
e.stopPropagation();
this.reselect();
}
});
}
get mode() {
return this.widgets.edit_mode.value || "mask";
}
stopOriginalPreview() {
if (!this.previewOriginal) return;
const pointer = this.originalPreviewPointer;
this.previewOriginal = false;
this.originalPreviewPointer = null;
if (pointer != null && this.editor.canvas.hasPointerCapture(pointer)) {
this.editor.canvas.releasePointerCapture(pointer);
}
this.revision++;
this.editor.schedule();
}
get region() {
try {
return JSON.parse(this.widgets.crop_state.value || "{}");
} catch {
return {};
}
}
state(value) {
this.widgets.crop_state.value = JSON.stringify(value);
this.revision++;
}
snapshot() {
return Object.fromEntries(fields.map((k) => [k, this.editor[k]]));
}
activate(target) {
if (this.target === target) return;
this.editor.flush();
this.contexts[this.target] = this.snapshot();
const next = this.contexts[target];
if (!next) return;
Object.assign(this.editor, next);
this.target = target;
this.editor.maskRevision++;
this.editor.updateButtons();
this.editor.schedule();
this.sync();
}
build() {
const e = this.editor;
this.modeSelect = make("select", "dd-mode-select");
this.modeSelect.setAttribute("aria-label", T("Editing mode"));
this.modeSelect.title = T("Mask: paint the full image. Crop & Blend: select a region, inpaint it, then adopt and blend the result.");
for (const [value, label] of [
["mask", "Mask"],
["crop", "Crop & Blend"],
]) {
const option = document.createElement("option");
option.value = value;
option.textContent = T(label);
this.modeSelect.append(option);
}
this.modeSelect.onchange = () => this.setMode(this.modeSelect.value);
e.root.append(this.modeSelect);
this.bar = make("div", "dd-crop-bar");
this.preset = make("select", "");
this.preset.setAttribute("aria-label", T("Crop size"));
this.preset.title = T("Crop resolution presets. Changing size releases the current selection; left-click the image to place a new region. Inpaint dimensions follow when selected.");
for (const [w, h] of CROP_PRESETS) {
const o = document.createElement("option");
o.value = `${w}x${h}`;
o.textContent = `${w} × ${h}`;
this.preset.append(o);
}
const custom = document.createElement("option");
custom.value = "custom";
custom.textContent = T("Custom");
this.preset.append(custom);
this.preset.onchange = () => this.run(() => {
const custom = this.preset.value === "custom";
const [width, height] = custom
? [this.cropWidth, this.cropHeight]
: this.preset.value.split("x").map(Number);
return this.changeSize(width, height, {custom});
});
this.bar.append(this.preset);
const button = (text, title, action) =>
e.button(this.bar, text, T(title), () => this.run(action));
this.swapButton = button("⇄", "Swap width and height, release the current selection, then left-click to place the new region.", () =>
this.changeSize(this.cropHeight, this.cropWidth),
);
// Shown in both modes: one click clears the mask of the active target.
// It sits right after the swap control in Crop & Blend, and is the only
// visible button in Mask mode.
this.clearButton = button("🗑", "Clear the mask", () => e.action({ type: "clear" }));
this.swapButton.after(this.clearButton);
this.maskButton = button("◌", "Paint inside the selected region. Right-click releases the selection; left-click places a new one. Alt: erase. Ctrl+wheel: brush size; Shift+wheel: hardness.", () => {
this.activate("mask");
this.changed();
});
this.blendButton = button(
"◐",
"Edit layer blending: brush keeps the new result; Alt restores original pixels. Hold right-click to preview the original, release to return.",
() =>
this.run(async () => {
// Blending is opt-in after adoption: build the blend mask on first
// use, then switch the editor into the blend layer.
if (!this.contexts.blend) await this.makeBlend(this.widgets.blend_data.value);
this.activate("blend");
this.changed();
}),
);
// Move the original live button, retaining its history and disabled state.
this.undoButton = e.undoButton;
this.undoButton.textContent = "↶";
this.undoButton.title = T("Undo the last stroke in the active repaint or blend mask (Ctrl+Z). Redo with Ctrl+Shift+Z.");
this.custom = make("div", "dd-crop-custom");
this.custom.hidden = true;
for (const key of ["cropWidth", "cropHeight"]) {
const input = make("input", "");
input.type = "number";
input.min = 64;
input.max = 16384;
input.step = 64;
input.value = this[key];
input.setAttribute(
"aria-label",
T(key === "cropWidth" ? "Width" : "Height"),
);
input.title = T("Custom crop dimension in 64-pixel steps. Changing it releases the selected region.");
input.onchange = () => this.run(() => {
const value = Math.max(64, Math.min(16384, Math.round(Number(input.value) / 64) * 64));
return this.changeSize(key === "cropWidth" ? value : this.cropWidth,
key === "cropHeight" ? value : this.cropHeight, {custom:true});
});
this.custom.append(input);
}
this.bar.append(this.custom);
e.root.append(this.bar);
this.sync();
}
async run(action) {
if (
this.busy ||
this.editor.stroke
)
return;
try {
await action();
} catch (error) {
this.editor.status(error.message, true);
}
}
sync() {
this.editor.root.classList.toggle("dd-crop-mode", this.mode === "crop");
this.modeSelect.value = this.mode;
// No hover text on the painting canvas; the bottom controls carry the hints.
this.editor.canvas.removeAttribute("title");
for (const control of [this.preset, this.swapButton, this.maskButton, this.blendButton]) {
control.hidden = this.mode !== "crop";
}
this.custom.hidden = this.mode !== "crop" || this.preset.value !== "custom";
this.undoButton.disabled = !!this.busy || !this.editor.history.length;
this.clearButton.disabled = !!this.busy || !this.editor.ready;
this.blendButton.disabled = !this.layer;
this.maskButton.classList.toggle("dd-active", this.target === "mask");
this.blendButton.classList.toggle("dd-active", this.target === "blend");
this.modeSelect.disabled = !!this.busy;
this.preset.disabled = !!this.busy;
this.swapButton.disabled = !!this.busy;
for (const input of this.custom.children)
input.disabled = !!this.busy;
}
setMode(mode) {
if (
this.editor.stroke ||
this.busy
) {
this.sync();
return;
}
if (mode === this.mode) {
this.sync();
return;
}
const apply = () => {
this.stopOriginalPreview();
this.activate("mask");
this.widgets.edit_mode.value = mode;
this.state({ ...this.region, job: crypto.randomUUID() });
this.editor.fit();
this.sync();
if (mode === "mask") syncCurrentDimensions(this.node);
this.changed();
};
const recovery = this.beforeModeChange?.();
if (!recovery?.then) {
if (recovery !== false) apply();
return;
}
this.busy = true;
this.sync();
return recovery
.then((ok) => {
this.busy = false;
if (ok !== false && !this.editor.destroyed) apply();
else this.sync();
})
.catch((error) => {
this.busy = false;
this.sync();
this.editor.status(error.message, true);
});
}
syncPreset() {
const value = `${this.cropWidth}x${this.cropHeight}`;
this.preset.value = [...this.preset.options].some((o) => o.value === value)
? value
: "custom";
this.custom.hidden = this.preset.value !== "custom";
[...this.custom.children].forEach(
(e, i) => (e.value = i ? this.cropHeight : this.cropWidth),
);
}
sizeChanged() {
this.hover = null;
this.editor.schedule();
this.syncPreset();
}
async changeSize(width, height, {custom = false} = {}) {
if (this.busy || this.editor.stroke) { this.syncPreset(); return; }
try {
// Use the same merge-before-reselect behavior as the reselect button.
if (this.region.locked) await this.reselect();
this.cropWidth = width;
this.cropHeight = height;
this.sizeChanged();
if (custom) { this.preset.value = "custom"; this.custom.hidden = false; }
this.sync();
this.changed();
} catch (error) {
this.syncPreset();
throw error;
}
}
reset() {
this.stopOriginalPreview();
this.layer = null;
this.widgets.layer_image.value = "";
this.widgets.blend_data.value = "";
this.contexts = {};
this.target = "mask";
this.hover = null;
this.state({ locked: false, job: crypto.randomUUID() });
const { width, height } = this.editor.image;
for (const o of this.preset.options)
if (o.value !== "custom") {
const [w, h] = o.value.split("x").map(Number);
o.disabled = w > width || h > height;
}
if (this.cropWidth > width || this.cropHeight > height) {
this.cropWidth = Math.min(1024, Math.floor(width / 64) * 64);
this.cropHeight = Math.min(1536, Math.floor(height / 64) * 64);
}
this.syncPreset();
this.sync();
this.editor.schedule();
}
lock(region) {
this.activate("mask");
this.contexts.blend = null;
this.layer = null;
this.widgets.layer_image.value = "";
this.widgets.blend_data.value = "";
this.state({ ...region, locked: true, job: crypto.randomUUID() });
const e = this.editor;
// A new crop only limits the painting area; the repaint mask starts empty.
for (const c of [e.mask, e.baseMask])
c.getContext("2d").clearRect(0, 0, c.width, c.height);
e.history = [];
e.future = [];
e.changed();
syncCropDimensions(this.node, region.width, region.height);
this.sync();
this.changed();
}
makeBlend(data = "") {
const r = this.region,
e = this.editor,
mask = canvas(e.image.width, e.image.height),
baseMask = canvas(e.image.width, e.image.height);
const ctx = mask.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(r.x, r.y, r.width, r.height);
baseMask.getContext("2d").drawImage(mask, 0, 0);
this.contexts.blend = {
mask,
baseMask,
history: [],
future: [],
lastMask: "",
dirty: false,
};
if (!data) return Promise.resolve();
return load(data).then((image) => {
const out = canvas(r.width, r.height),
c = out.getContext("2d");
c.drawImage(image, 0, 0);
const pixels = c.getImageData(0, 0, r.width, r.height);
for (let i = 0; i < pixels.data.length; i += 4) {
pixels.data[i + 3] = pixels.data[i];
pixels.data[i] = pixels.data[i + 1] = pixels.data[i + 2] = 255;
}
ctx.clearRect(0, 0, mask.width, mask.height);
ctx.putImageData(pixels, r.x, r.y);
const base = baseMask.getContext("2d");
base.clearRect(0, 0, baseMask.width, baseMask.height);
base.drawImage(mask, 0, 0);
out.width = out.height = 0;
});
}
async restore() {
this.stopOriginalPreview();
this.target = "mask";
this.contexts = {};
this.layer = null;
const r = this.region;
if (r.locked) {
this.cropWidth = r.width;
this.cropHeight = r.height;
this.syncPreset();
if (this.widgets.layer_image.value) {
this.layer = await load(this.imageUrl(this.widgets.layer_image.value));
await this.makeBlend(this.widgets.blend_data.value);
// Restore the state the user left: a non-empty blend mask means they were
// still adjusting the blend layer, otherwise the adopted layer is already
// baked and we stay on the repaint mask.
if (this.widgets.blend_data.value && this.mode === "crop") this.activate("blend");
}
}
this.revision++;
this.sync();
this.editor.schedule();
}
async accept(record, {allowBusy = false, image = null} = {}) {
if (
this.editor.destroyed ||
this.mode !== "crop" ||
!this.region.locked ||
record.job !== this.region.job ||
(this.busy && !allowBusy)
)
return;
if (this.editor.stroke) {
this.pendingResult = record;
clearTimeout(this.resultTimer);
this.resultTimer = setTimeout(() => {
const next = this.pendingResult;
this.pendingResult = null;
this.accept(next, {allowBusy}).catch((error) =>
this.editor.status(error.message, true),
);
}, 50);
return;
}
const name = record.image.subfolder
? `${record.image.subfolder}/${record.image.filename}`
: record.image.filename;
if (name === this.widgets.layer_image.value) return;
const version = ++this.acceptVersion;
// The adoption path already decoded this image once; reuse it instead of
// downloading and decoding the full-size result a second time.
const decoded = image || await load(this.imageUrl(name));
if (
this.editor.destroyed ||
version !== this.acceptVersion ||
record.job !== this.region.job ||
this.mode !== "crop"
)
return;
this.layer = decoded;
this.widgets.layer_image.value = name;
// Adopt then return: with an empty blend mask the backend hard-composites
// the whole crop from layer_image, so the result is immediately baked into
// the source and the repaint mask is reused on the next generation. Blending
// is no longer entered automatically; the ◐ button opens it on demand.
this.activate("mask");
this.revision++;
this.sync();
this.editor.schedule();
this.changed();
}
drawSource(ctx, {preview = false} = {}) {
if (this.mode !== "crop") return false;
const e = this.editor,
r = this.region;
ctx.drawImage(e.image, 0, 0);
if (!preview && this.layer && r.locked) {
const key = `${this.revision}:${e.maskRevision}`;
if (this.patchKey !== key) {
this.patchCanvas.width = r.width;
this.patchCanvas.height = r.height;
const c = this.patchCanvas.getContext("2d");
c.drawImage(this.layer, 0, 0);
const mask =
this.target === "blend" ? e.mask : this.contexts.blend?.mask;
if (mask) {
c.globalCompositeOperation = "destination-in";
c.drawImage(
mask,
r.x,
r.y,
r.width,
r.height,
0,
0,
r.width,
r.height,
);
c.globalCompositeOperation = "source-over";
}
this.patchKey = key;
}
ctx.drawImage(this.patchCanvas, r.x, r.y);
}
return true;
}
drawOverlay(ctx, d) {
if (this.mode !== "crop") return;
const r = this.region.locked ? this.region : this.hover;
if (!r) return;
ctx.save();
if (this.region.locked) {
ctx.fillStyle = "#0008";
ctx.beginPath();
ctx.rect(0, 0, this.editor.image.width, this.editor.image.height);
ctx.rect(r.x, r.y, r.width, r.height);
ctx.fill("evenodd");
}
ctx.strokeStyle = "#000000";
ctx.lineWidth = 1 / d.scale;
ctx.setLineDash([6 / d.scale, 4 / d.scale]);
ctx.strokeRect(r.x, r.y, r.width, r.height);
ctx.restore();
}
async flatten() {
if (!this.layer) return;
this.stopOriginalPreview();
this.editor.flush();
this.busy = true;
this.sync();
// Releasing an adopted crop must first preserve the visible composite on
// the server. Show its real upload progress, particularly on remote hosts;
// do not unlock or discard the layer if that upload fails.
const e = this.editor;
const version = e.uploadUiVersion = (e.uploadUiVersion || 0) + 1;
const progress = ({phase, loaded = 0, total = 0}) => {
if (version !== e.uploadUiVersion || e.destroyed) return;
e.uploadIndicator.hidden = false;
if (phase === "upload" && total > 0) {
const percent = Math.max(0, Math.min(100, Math.round(100 * loaded / total)));
e.uploadLabel.textContent = `${T("Uploading image")} ${percent}%`;
e.uploadBar.value = percent;
} else {
e.uploadLabel.textContent = T(phase === "processing" ? "Upload complete, loading image…" : phase === "authenticated" ? "Uploading with authentication…" : "Uploading image…");
e.uploadBar.removeAttribute("value");
}
};
progress({phase:"upload"});
try {
const c = canvas(e.image.width, e.image.height);
this.drawSource(c.getContext("2d"));
const blob = await new Promise((resolve) =>
c.toBlob(resolve, "image/png"),
);
c.width = c.height = 0;
this.activate("mask");
await e.options.onUpload(
new File([blob], `merged-${crypto.randomUUID()}.png`, {
type: "image/png",
}),
{ preserveMask: true, preserveCrop: true, onProgress: progress },
);
e.status("");
this.layer = null;
this.widgets.layer_image.value = "";
this.widgets.blend_data.value = "";
for(const key of ['mask','baseMask']){const raster=this.contexts.blend?.[key];if(raster && raster!==e.mask && raster!==e.baseMask)raster.width=raster.height=0;}
this.contexts.blend = null;
this.state({ ...this.region, job: crypto.randomUUID() });
} finally {
if (version === e.uploadUiVersion && !e.destroyed) e.uploadIndicator.hidden = true;
this.busy = false;
this.sync();
this.editor.schedule();
this.changed();
}
}
async reselect() {
if (
this.busy ||
this.editor.stroke
)
return;
await this.flatten();
this.activate("mask");
this.state({ locked: false, job: crypto.randomUUID() });
this.hover = null;
this.sync();
this.editor.schedule();
this.changed();
}
destroy() {
this.adoptController?.abort();
this.stopOriginalPreview();
window.removeEventListener("blur", this.previewBlur);
for (const name of ["pointerup", "pointercancel"]) {
window.removeEventListener(name, this.finishPreview, true);
}
this.editor.canvas.removeEventListener("lostpointercapture", this.finishPreview, true);
clearTimeout(this.resultTimer);
this.pendingResult = null;
this.acceptVersion++;
for (const context of Object.values(this.contexts))
for (const key of ["mask", "baseMask"])
if (context?.[key] !== this.editor[key] && context?.[key])
context[key].width = context[key].height = 0;
this.patchCanvas.width = this.patchCanvas.height = 0;
}
}
+157 -37
View File
@@ -1,14 +1,22 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { InlineMaskEditor } from "./editor.js";
import { InlineMaskEditor } from "./editor.js?v=2.1.0";
import { installInpaintWorkbench } from "./inpaint_node.js?v=toolbar-layout-20261112";
import { bindWidgetToNodeWidth, createWidgetHost } from "./widget_layout.js?v=stable-host-20261112";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { InlineCropController } from "./inline_crop.js?v=2.1.0";
import { canStoreDraft, prepareInlineDraft, syncInlineWidgets } from "./draft_guard.js?v=2.1.0";
import { T } from "./i18n.js";
import { removeLegacyFeedback } from "./legacy_feedback.js?v=0.6.4";
import { uploadImageWithProgress } from "./upload_transport.js?v=stability1";
import { ColorPaint } from "./color_paint.js?v=toolbar-layout-20261112";
import { createPaintDraftProtection } from "./paint_draft.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=toolbar-layout-20261112", import.meta.url).href;
if (!stylesheet.isConnected) document.head.append(stylesheet);
function hide(widget) {
if (!widget) return;
@@ -34,9 +42,45 @@ function imageUrl(value) {
return api.apiURL(`/view?${params}`);
}
function draftOptions() {
let workspace = "personal";
try { const current = JSON.parse(sessionStorage.getItem("Comfy.Workspace.Current") || "null"); if (current?.type !== "personal" && current?.id) workspace = current.id; } catch { /* Personal workspace. */ }
return { workspace, path: app.extensionManager?.workflow?.activeWorkflow?.path || "" };
}
async function uploadEmbeddedSource(embedded) {
const blob = await (await fetch(embedded)).blob();
if (blob.size > 64 * 1024 * 1024) throw Error(T("File is too large (max 64 MB)"));
const body = new FormData();
body.append("image", blob, `source-${crypto.randomUUID()}.png`);
body.append("type", "input"); body.append("subfolder", "adachi-inpaint");
const response = await api.fetchApi("/upload/image", {method:"POST", body});
if (!response.ok) throw Error(`${T("Upload failed")} (${response.status})`);
const uploaded = await response.json();
if (typeof uploaded.name !== "string" || !uploaded.name) throw Error(T("Upload failed"));
return uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
}
app.registerExtension({
name: "Adachi.InlineInpaint",
async beforeConfigureGraph(graphData) {
removeLegacyFeedback(graphData);
if (app.ui.settings.getSettingValue("Comfy.Workflow.Persist") === false) return;
try {
// The active workflow can still be the previous tab at this point;
// do not claim its replacement credit for an incoming workflow.
const options = { ...draftOptions(), path: "" };
const count = await prepareInlineDraft(graphData, {
fits: graph => canStoreDraft(graph, options), upload: uploadEmbeddedSource,
});
if (count) console.info(`[Adachi Inpaint] Saved ${count} oversized embedded source or paint layer(s) as input files before workflow draft initialization.`);
} catch (error) {
console.warn("[Adachi Inpaint] Draft preflight failed; embedded source data was retained.", error);
}
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === "AdachiLayerCommit") { nodeType.skip_list = true; return; }
if (["AdachiWebUIInpaint", "AdachiInpaintPrepare"].includes(nodeData.name)) { installInpaintWorkbench(nodeType); return; }
if (nodeData.name !== "AdachiInlineMask") return;
const originalCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
@@ -51,76 +95,148 @@ app.registerExtension({
node.graph?.setDirtyCanvas(true, true);
};
const find = name => node.widgets?.find(widget => widget.name === name);
const imageWidget = find("image"), maskWidget = find("mask_data"), sourceWidget = find("source_data");
for (const widget of [imageWidget, maskWidget, sourceWidget]) hide(widget);
const imageWidget = find("image"), maskWidget = find("mask_data"), sourceWidget = find("source_data"), paintWidget=find("paint_data");
for (const widget of [imageWidget, maskWidget, sourceWidget, paintWidget]) hide(widget);
let crop;
let draftRecovery = null;
let draftNoticeTimer = 0;
let restoring = false;
let restoreFrame = 0;
let uploadVersion = 0;
let fitFrame = 0;
const fitNodeToImage = () => {
cancelAnimationFrame(fitFrame);
fitFrame = requestAnimationFrame(() => {
if (!editor.ready || editor.destroyed || !node.graph || editor.root.closest(".adachi-workspace")) return;
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();
});
};
const restore = async () => {
const embedded = sourceWidget?.value;
const filename = imageWidget?.value;
if (!embedded && !filename) return;
restoring = true;
editor.embed.checked = !!embedded;
try { await editor.setImage(embedded || imageUrl(filename), maskWidget?.value || "", embedded ? "内嵌原图 / Embedded image" : filename); }
catch (error) { console.warn("[Adachi Inpaint]", error); }
try { crop?.activate("mask"); await editor.setImage(embedded || imageUrl(filename), maskWidget?.value || "", embedded ? T("Embedded image") : filename); await crop?.restore(); await node.adachiColorPaint?.restore(paintWidget?.value || ""); }
catch (error) { console.warn("[Adachi Inpaint]", error); editor.status(error.message, true); }
finally { restoring = false; }
};
const editor = new InlineMaskEditor({
compact: true,
onChange(data) { if (maskWidget && !restoring) maskWidget.value = data; },
onDirty() { markChanged(); },
async onUpload(file) {
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, preserveCrop = false, onProgress = null, signal } = {}) {
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the current image to finish loading"));
const version = ++uploadVersion;
const body = new FormData();
body.append("image", file, file.name);
body.append("type", "input");
body.append("subfolder", "adachi-inpaint");
const response = await api.fetchApi("/upload/image", { method: "POST", body });
if (!response.ok) throw new Error(`上传失败 / Upload failed (${response.status})`);
const uploaded = await response.json();
const uploaded = await uploadImageWithProgress(api, body, onProgress, {signal});
if (typeof uploaded?.name !== "string" || !uploaded.name) throw new Error(T("Upload failed"));
if (version !== uploadVersion || editor.destroyed) return;
signal?.throwIfAborted();
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();
crop?.activate("mask");
let kept = false;
if (preserveMask && editor.ready) {
kept = await editor.replaceSource(imageUrl(name), file.name, {retry:true,signal});
if (version !== uploadVersion || editor.destroyed) return;
// A different-sized replacement cannot reuse the old mask.
// Actually load it as a fresh image before clearing state.
if (!kept) await editor.setImage(imageUrl(name), "", file.name, {retry:true,signal});
} else await editor.setImage(imageUrl(name), "", file.name, {retry:true,signal});
if (version !== uploadVersion || editor.destroyed) return;
if (!preserveCrop) node.adachiColorPaint?.clear();
imageWidget.value = name;
maskWidget.value = kept ? editor.flush() : "";
sourceWidget.value = editor.embed.checked ? editor.sourceData() : "";
if (!preserveCrop) crop?.reset();
await beforeModeChange();
if (preserveMask && !kept) editor.notice(T("The new image has a different size, so the mask was reset"));
markChanged();
},
onEmbed(enabled) {
if (sourceWidget) sourceWidget.value = enabled ? editor.sourceData() : "";
const recovery = enabled ? beforeModeChange() : null;
if (recovery?.then) return recovery.then(() => markChanged()).catch(error => editor.status(error.message, true));
markChanged();
},
});
node.adachiEditor = editor;
node.adachiUseResult = async file => {
if (editor.destroyed) throw new Error("内联蒙版节点已移除");
await editor.options.onUpload(file);
editor.view = "overlay"; editor.updateButtons(); editor.schedule();
editor.status("已采用重绘结果作为新原图,旧蒙版已清空");
editor.root.dataset.adachiVersion = "0.6.7";
const beforeModeChange = () => {
if (draftRecovery) return draftRecovery;
const embedded = sourceWidget?.value;
if (!editor.ready || !embedded?.startsWith("data:") || embedded.length < 256 * 1024) return;
if (app.ui.settings.getSettingValue("Comfy.Workflow.Persist") === false) return;
const graph = app.rootGraph || node.graph;
if (canStoreDraft(graph.serialize(), draftOptions()) !== false) return;
// Persist a fresh, exact copy before dropping any embedded bytes.
// This also repairs portable workflows whose original input path is absent.
draftRecovery = (async () => {
const version = editor.loadVersion;
const name = await uploadEmbeddedSource(embedded);
if (editor.destroyed || editor.loadVersion !== version || sourceWidget.value !== embedded) return false;
imageWidget.value = name;
sourceWidget.value = ""; editor.embed.checked = false;
editor.message.textContent = T("Browser draft storage was full. The source was saved as an input file; the image, masks and layers are preserved.");
editor.message.classList.remove("dd-error"); editor.message.hidden = false;
const notice = editor.message.textContent;
clearTimeout(draftNoticeTimer);
draftNoticeTimer = setTimeout(() => { if (!editor.destroyed && editor.message.textContent === notice) editor.message.hidden = true; }, 8000);
return true;
})().finally(() => { draftRecovery = null; });
return draftRecovery;
};
const domWidget = node.addDOMWidget("adachi_canvas", "adachi_inline_mask", editor.root, {
crop = new InlineCropController(node, editor, {find, hide, changed:markChanged, imageUrl, beforeModeChange});
node.adachiCrop = crop;
node.adachiEditor = editor;
const paintDraft=createPaintDraftProtection({paint:()=>node.adachiColorPaint,widget:()=>paintWidget,graph:()=>app.rootGraph||node.graph,disposed:()=>editor.destroyed||!node.graph,persist:()=>app.ui.settings.getSettingValue('Comfy.Workflow.Persist')!==false,fits:graph=>canStoreDraft(graph.serialize(),draftOptions()),upload:uploadEmbeddedSource,changed:markChanged,onError:error=>editor.status(`${T("Paint layer could not be saved as an input file; embedded data was retained")}: ${error.message}`,true)});
node.adachiColorPaint = new ColorPaint(editor,crop,paintWidget,paintDraft.schedule,imageUrl);
editor.colorPaint=node.adachiColorPaint;
const acceptCrop=crop.accept.bind(crop);
crop.accept=async(...args)=>{const old=crop.widgets.layer_image.value,region={...crop.region};const result=await acceptCrop(...args);if(crop.widgets.layer_image.value && crop.widgets.layer_image.value!==old)node.adachiColorPaint.clearRegion(region);return result;};
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
node.adachiUseResult = async (file, origin, options={}) => {
if (editor.destroyed) throw new Error(T("The inline mask node was removed"));
if (crop.mode === "crop") return node.adachiAdoptCrop(file, origin, options);
await editor.options.onUpload(file, { preserveMask: true, signal:options.signal, onProgress:options.onProgress });
editor.view = "overlay"; editor.updateButtons(); editor.schedule();
editor.status(T("Adopted the result as the new source image; the mask and undo history were preserved"));
};
const widgetHost = createWidgetHost(editor.root);
const domWidget = node.addDOMWidget("adachi_canvas", "adachi_inline_mask", widgetHost, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 520,
getMinHeight: () => 170,
getMaxHeight: () => 4096,
});
domWidget.serialize = false;
bindWidgetToNodeWidth(node, domWidget, widgetHost);
// Leave computeLayoutSize in charge: a fixed computeSize height
// prevents the DOM canvas from growing with the node vertically.
const resizeHandle = document.createElement("div");
resizeHandle.className = "dd-resize-handle";
resizeHandle.textContent = "↕";
resizeHandle.title = "上下拖动调整画布高度 / Drag to resize canvas height";
resizeHandle.title = T("Drag to resize canvas height");
resizeHandle.tabIndex = 0;
resizeHandle.setAttribute("role", "separator");
resizeHandle.setAttribute("aria-orientation", "horizontal");
resizeHandle.setAttribute("aria-label", "调整画布高度 / Resize canvas height");
resizeHandle.setAttribute("aria-label", T("Resize canvas height"));
editor.root.append(resizeHandle);
let resizing = null;
const resizeHeight = height => {
node.setSize([node.size[0], Math.max(620, Math.min(4200, height))]);
node.setSize([node.size[0], Math.max(240, Math.min(4200, height))]);
node.graph?.setDirtyCanvas(true, true);
editor.schedule();
};
@@ -153,28 +269,32 @@ app.registerExtension({
const previousSerialize = maskWidget.serializeValue;
maskWidget.serializeValue = function () {
editor.flush();
if(node.adachiColorPaint.drawing) node.adachiColorPaint.flush();
return previousSerialize ? previousSerialize.apply(this, arguments) : maskWidget.value;
};
const previousSerializeNode = node.onSerialize;
node.onSerialize = function (data) {
const result = previousSerializeNode?.apply(this, arguments);
editor.flush();
const index = node.widgets.indexOf(maskWidget);
if (data?.widgets_values && index >= 0) data.widgets_values[index] = maskWidget.value;
return previousSerializeNode?.apply(this, arguments);
if(node.adachiColorPaint.drawing) node.adachiColorPaint.flush();
if (data) syncInlineWidgets(data, node.widgets);
return result;
};
const previousQueued = maskWidget.beforeQueued;
maskWidget.beforeQueued = function () { editor.flush(); return previousQueued?.apply(this, arguments); };
maskWidget.beforeQueued = function () { editor.flush(); if(node.adachiColorPaint.drawing) node.adachiColorPaint.flush(); return previousQueued?.apply(this, arguments); };
if(paintWidget){const previousPaintSerialize=paintWidget.serializeValue;paintWidget.serializeValue=function(){if(node.adachiColorPaint.drawing)node.adachiColorPaint.flush();return previousPaintSerialize?previousPaintSerialize.apply(this,arguments):paintWidget.value;};}
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;
};
const previousImageCallback = imageWidget.callback;
imageWidget.callback = function () { previousImageCallback?.apply(this, arguments); restore(); };
const previousRemoved = node.onRemoved;
node.onRemoved = function () { cancelAnimationFrame(restoreFrame); editor.destroy(); return previousRemoved?.apply(this, arguments); };
node.setSize([Math.max(510, node.size[0]), Math.max(650, node.size[1])]);
node.onRemoved = function () { node.adachiWorkspaceHistory?.clear();node.adachiWorkspaceHistory=null;clearTimeout(draftNoticeTimer);paintDraft.dispose(); cancelAnimationFrame(restoreFrame); cancelAnimationFrame(fitFrame); stopWheelRouting(); node.adachiColorPaint.destroy();crop.destroy(); editor.destroy(); return previousRemoved?.apply(this, arguments); };
node.setSize([Math.max(380, node.size[0]), Math.max(480, node.size[1])]);
restoreFrame = requestAnimationFrame(restore);
return result;
};
+126
View File
@@ -0,0 +1,126 @@
import { app } from "../../scripts/app.js";
import { InpaintParameterPanel } from "./inpaint_panel.js?v=toolbar-layout-20261112";
import { connectedEditor, connectedSamplers, relocateWidgets, syncCurrentDimensions } from "./parameter_bridge.js?v=0.8.2";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth, createWidgetHost } from "./widget_layout.js?v=stable-host-20261112";
export function installInpaintWorkbench(nodeType) {
const created = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = created?.apply(this, arguments), node = this;
const root = document.createElement("div"); root.className = "dd-inpaint dd-workbench";
if (node.comfyClass === "AdachiWebUIInpaint" || node.type === "AdachiWebUIInpaint") {
const launch = document.createElement("button");
launch.type = "button"; launch.textContent = T("Open Adachi Workbench");
launch.className = "adachi-workspace-launch";
launch.onclick = event => { event.preventDefault(); event.stopPropagation(); document.dispatchEvent(new CustomEvent("adachi-open-workspace", {detail:{sampler:node}})); };
root.append(launch);
}
const fold = document.createElement("details"); fold.className = "dd-workbench-fold";
const summary = document.createElement("summary"); summary.textContent = T("Painting tools and mask settings");
summary.addEventListener("pointerdown", event => event.stopPropagation());
summary.addEventListener("click", event => event.stopPropagation());
fold.append(summary);
const tools = document.createElement("div"); tools.className = "dd-workbench-tools";
const placeholder = document.createElement("div"); placeholder.className = "dd-param-placeholder dd-tools-placeholder";
placeholder.textContent = T("Connect an inline mask to show the painting tools");
tools.append(placeholder); fold.append(tools);
const actionHost = document.createElement("div"); actionHost.className = "dd-workbench-actions";
root.append(fold, actionHost);
let source = null, mountedEditor = null, frame = 0, removed = false;
const markChanged = () => {
app.canvas?.emitBeforeChange?.(); app.canvas?.emitAfterChange?.();
node.graph?.setDirtyCanvas(true, true);
};
const refreshEditor = () => {
source = connectedEditor(node);
const editor = source?.adachiEditor;
// A live control tree has one host. Additional samplers still get
// their own parameters and use the same source image dimensions.
const canMount = editor?.controls && connectedSamplers(source)[0] === node;
const next = canMount ? editor : null;
if (next !== mountedEditor) {
if (mountedEditor?.controls.parentElement === tools) mountedEditor.controls.remove();
if (mountedEditor?.actions.parentElement === actionHost) mountedEditor.actions.remove();
mountedEditor = next;
if (next) { tools.append(next.controls); actionHost.append(next.actions); }
scheduleSize();
}
placeholder.hidden = !!next;
placeholder.textContent = editor ? T("The painting tools live in the other inpaint node connected to this image") : T("Connect an inline mask to show the painting tools");
};
const contentHeight = () => (root.querySelector('.adachi-workspace-launch')?.offsetHeight || 0) + fold.offsetHeight + actionHost.offsetHeight + (panel?.root.scrollHeight || 620);
const scheduleSize = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (removed || !node.graph || !root.isConnected || root.closest(".adachi-workspace")) return;
const required = node.computeSize()[1];
if (Math.abs(node.size[1] - required) > 1) node.setSize([node.size[0], required]);
node.graph.setDirtyCanvas(true, true);
});
};
let panel;
fold.open = !!node.properties.adachi_tools_open;
fold.ontoggle = () => {
if (node.properties.adachi_tools_open !== fold.open) {
node.properties.adachi_tools_open = fold.open; markChanged();
}
scheduleSize();
};
panel = new InpaintParameterPanel({
getTargets: () => { refreshEditor(); return [node]; },
getImage: () => source?.adachiEditor?.ready ? (source.adachiCrop?.mode === "crop" && source.adachiCrop.region.locked ? source.adachiCrop.region : source.adachiEditor.image) : null,
onTargetChange: (previous, next) => relocateWidgets(node, previous, next),
onDirty: markChanged, onHeight: scheduleSize,
onMaskSettings: element => {
fold.querySelector(".dd-param-mask-settings")?.remove();
fold.append(element); scheduleSize();
},
});
root.append(panel.root);
node.adachiParameters = panel; node.adachiWorkbench = { root, tools, fold, actionHost, get source() { return source; } };
const stopWheelRouting = routeWheelOutsideImage(root, null, () => false);
const widgetHost = createWidgetHost(root);
const widget = node.addDOMWidget("adachi_workbench", "adachi_inpaint_controls", widgetHost, {
serialize: false, hideOnZoom: false, getMinHeight: contentHeight, getMaxHeight: () => Math.max(4096, contentHeight()),
});
widget.serialize = false;
bindWidgetToNodeWidth(node, widget, widgetHost);
const observer = new ResizeObserver(scheduleSize); observer.observe(tools); observer.observe(fold); observer.observe(actionHost);
const configured = node.onConfigure;
node.onConfigure = function () {
const result = configured?.apply(this, arguments);
const legacyTitles = ["WebUI Inpaint", "WebUI 局部重绘", "WebUI 局部重绘 / WebUI Inpaint", "安达局部重绘工作台"];
if (node.type === "AdachiWebUIInpaint" && legacyTitles.includes(node.title)) {
node.title = T("Adachi Inpaint Workbench");
}
fold.open = !!node.properties.adachi_tools_open;
queueMicrotask(() => { if (!removed) { panel.refresh(); scheduleSize(); } });
return result;
};
const connections = node.onConnectionsChange;
node.onConnectionsChange = function (type, slot, connected) {
const result = connections?.apply(this, arguments);
// Graph restoration also connects wires: never overwrite the sizes
// saved in a workflow while it is being configured.
const userImageConnection = !app.configuringGraph && type === 1 && connected && node.inputs?.[slot]?.name === "image";
queueMicrotask(() => {
if (removed) return;
panel.refresh();
if (userImageConnection) syncCurrentDimensions(connectedEditor(node), node);
});
return result;
};
const previousRemoved = node.onRemoved;
node.onRemoved = function () {
removed = true; cancelAnimationFrame(frame); observer.disconnect();
if (mountedEditor?.controls.parentElement === tools) mountedEditor.controls.remove();
if (mountedEditor?.actions.parentElement === actionHost) mountedEditor.actions.remove();
panel.destroy(); stopWheelRouting(); root.remove();
return previousRemoved?.apply(this, arguments);
};
node.setSize([Math.max(510, node.size[0]), node.size[1]]); scheduleSize();
return result;
};
}
+153
View File
@@ -0,0 +1,153 @@
/* Inline controls edit the connected sampler's real widgets, not a second set of parameters. */
import { T } from "./i18n.js";
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text) e.textContent = text; return e; };
export const INPAINT_FIELDS = ["mask_mode", "fill", "area", "padding", "width", "height", "denoise", "sampler_name", "scheduler", "steps", "cfg", "seed", "control_after_generate", "batch_size", "mask_blur", "mask_grow", "resize_mode", "inpaint_model", "soft_edge", "soft_strength", "refiner_switch"];
const choices = {
mask_mode: { inside: "Inside mask", outside: "Outside mask" },
fill: { fill: "Fill", original: "Original pixels", latent_noise: "Latent noise", latent_nothing: "Empty latent" },
area: { whole_image: "Whole image", only_masked: "Masked area only" },
resize_mode: { stretch: "Stretch", crop: "Center crop", fill: "Aspect fill" },
control_after_generate: { fixed: "Fixed", increment: "Increment", decrement: "Decrement", randomize: "Randomize" },
};
export class InpaintParameterPanel {
constructor(options) {
this.options = options; this.bindings = []; this.root = el("section", "dd-parameters");
const header = el("div", "dd-param-header"); header.append(el("strong", "", T("Adachi Inpaint Workbench")));
this.targetSelect = el("select"); this.targetSelect.setAttribute("aria-label", T("Controlled WebUI inpaint node"));
this.targetSelect.onchange = () => { this.preferred = this.targetSelect.value; this.options.onPreferred?.(this.preferred); this.refresh(); };
header.append(this.targetSelect); this.root.append(header);
this.placeholder = el("div", "dd-param-placeholder", T("Connect a WebUI inpaint node to edit its parameters here"));
this.body = el("div", "dd-param-body"); this.root.append(this.placeholder, this.body);
this.observer = new ResizeObserver(() => options.onHeight?.()); this.observer.observe(this.root);
this.timer = setInterval(() => { if (this.root.isConnected && !document.hidden) this.refresh(); }, 350);
this.refresh();
}
refresh() {
const targets = this.options.getTargets?.() || [];
const preferred = this.preferred || this.options.getPreferred?.();
const next = targets.find(n => String(n.id) === String(preferred)) || targets[0] || null;
const signature = targets.map(n => `${n.id}:${n.title}`).join("|");
if (signature !== this.signature) {
this.signature = signature; this.targetSelect.replaceChildren();
for (const n of targets) { const option = el("option", "", `${n.title || T("Adachi Inpaint Workbench")} #${n.id}`); option.value = String(n.id); this.targetSelect.append(option); }
}
this.targetSelect.hidden = targets.length < 2;
if (next) this.targetSelect.value = String(next.id);
if (next !== this.target) {
const previous = this.target; this.target = next;
this.options.onTargetChange?.(previous, next);
this.bindings = []; this.body.replaceChildren();
if (next) this.build();
this.options.onHeight?.();
}
this.placeholder.hidden = !!next; this.body.hidden = !next;
for (const binding of this.bindings) binding.sync();
if (this.sourceSize) this.sourceSize.disabled = !this.options.getImage?.();
}
widget(name) { return this.target?.widgets?.find(w => w.name === name); }
linked(name) { return this.target?.inputs?.some(input => input.name === name && input.link != null); }
write(name, value, commit = false) {
const widget = this.widget(name); if (!widget || this.linked(name)) return;
if (widget.value !== value) { widget.value = value; widget.callback?.(value); }
if (commit) this.options.onDirty?.();
}
enum(parent, name, label, pills = false) {
const widget = this.widget(name); if (!widget) return;
label = T(label);
const wrapper = el("div", "dd-param-field"); wrapper.dataset.adachiParam = name; wrapper.append(el("span", "dd-param-label", label));
let values = widget.options?.values; if (typeof values === "function") values = values();
if (!Array.isArray(values)) values = Object.keys(choices[name] || {});
if (!values.includes(widget.value)) values = [widget.value, ...values];
const display = value => choices[name]?.[value] ? T(choices[name][value], "enum") : String(value);
if (pills) {
const row = el("div", "dd-param-pills");
const buttons = values.map(value => {
const button = el("button", "", display(value)); button.type = "button";
button.onclick = () => { this.write(name, value, true); this.refresh(); }; row.append(button); return { button, value };
});
wrapper.append(row); this.bindings.push({ sync: () => { for (const {button,value} of buttons) { button.classList.toggle("dd-active", widget.value === value); button.setAttribute("aria-pressed", String(widget.value === value)); button.disabled = this.linked(name); } } });
} else {
const select = el("select"); select.setAttribute("aria-label", label);
for (const value of values) { const option = el("option", "", display(value)); option.value = value; select.append(option); }
select.onchange = () => this.write(name, select.value, true); wrapper.append(select);
this.bindings.push({ sync: () => { if (document.activeElement !== select) select.value = widget.value; select.disabled = this.linked(name); } });
}
parent.append(wrapper);
}
number(parent, name, label, { min, max, step = 1, range = false, rangeMax = max } = {}) {
const widget = this.widget(name); if (!widget) return;
label = T(label);
min ??= widget.options?.min ?? 0; max ??= widget.options?.max ?? 1000000;
const wrapper = el("label", "dd-param-field"); wrapper.dataset.adachiParam = name; const row = el("span", "dd-param-number-label"); row.append(el("span", "", label));
const input = el("input"); input.type = "number"; input.min = min; input.max = max; input.step = step; input.setAttribute("aria-label", label); row.append(input); wrapper.append(row);
let slider;
const update = raw => {
if (raw === "" || !Number.isFinite(Number(raw))) return;
const value = Math.max(min, Math.min(max, Math.round(Number(raw) / step) * step));
this.write(name, Number(value.toFixed(step < 1 ? Math.ceil(-Math.log10(step)) : 0)));
if (slider) slider.value = widget.value;
};
input.oninput = () => update(input.value); input.onchange = () => { update(input.value); input.value = widget.value; this.options.onDirty?.(); };
if (range) {
slider = el("input"); slider.type = "range"; slider.min = min; slider.max = rangeMax ?? max; slider.step = step; slider.setAttribute("aria-label", `${label} slider`);
slider.oninput = () => { update(slider.value); input.value = widget.value; }; slider.onchange = () => this.options.onDirty?.(); wrapper.append(slider);
}
this.bindings.push({ sync: () => {
if (document.activeElement !== input) input.value = widget.value;
if (slider && document.activeElement !== slider) slider.value = widget.value;
input.disabled = this.linked(name); if (slider) slider.disabled = input.disabled;
} }); parent.append(wrapper);
}
toggle(parent, name, label) {
const widget = this.widget(name); if (!widget) return;
const wrapper = el("label", "dd-param-toggle"), input = el("input"); input.type = "checkbox";
input.onchange = () => this.write(name, input.checked, true); wrapper.append(input, document.createTextNode(T(label))); parent.append(wrapper);
this.bindings.push({ sync: () => { input.checked = !!widget.value; input.disabled = this.linked(name); } });
}
grid(parent) { const grid = el("div", "dd-param-grid"); parent.append(grid); return grid; }
build() {
this.maskSettings = el("div", "dd-param-mask-settings");
this.enum(this.maskSettings, "mask_mode", "Mask mode", true);
this.enum(this.maskSettings, "fill", "Fill content", true);
this.body.append(this.maskSettings);
this.options.onMaskSettings?.(this.maskSettings);
this.enum(this.body, "area", "Sampling area", true);
const resolution = el("div", "dd-param-resolution"); resolution.dataset.adachiParam = "resolution"; resolution.append(el("span", "dd-param-label", T("Sampling resolution")));
this.sourceSize = el("button", "", T("Use source size")); this.sourceSize.type = "button";
this.sourceSize.title = T("Fill width and height from the current source image; aligned to 8 px and clamped to 64-8192 px");
this.sourceSize.onclick = () => {
const image = this.options.getImage?.(); if (!image) return;
const aligned = value => Math.max(64, Math.min(8192, Math.round(value / 8) * 8));
this.write("width", aligned(image.naturalWidth || image.width)); this.write("height", aligned(image.naturalHeight || image.height));
this.options.onDirty?.(); this.refresh();
};
resolution.append(this.sourceSize); this.body.append(resolution);
this.number(this.body, "width", "Width", { min: 64, max: 8192, step: 8, range: true });
this.number(this.body, "height", "Height", { min: 64, max: 8192, step: 8, range: true });
this.number(this.body, "denoise", "Denoise", { min: 0, max: 1, step: .01, range: true });
const sampling = this.grid(this.body); this.enum(sampling, "sampler_name", "Sampler"); this.enum(sampling, "scheduler", "Scheduler");
const scale = this.grid(this.body);
this.number(scale, "steps", "Steps", { min: 1, max: 10000, range: true, rangeMax: 150 });
this.number(scale, "cfg", "CFG scale", { min: 0, max: 100, step: .1, range: true, rangeMax: 30 });
const seed = this.grid(this.body); this.number(seed, "seed", "Seed", { min: 0, max: Number.MAX_SAFE_INTEGER }); this.enum(seed, "control_after_generate", "After generate");
const advanced = el("details", "dd-param-advanced"), summary = el("summary", "", T("Mask, batch and advanced settings")); advanced.append(summary);
const content = el("div", "dd-param-advanced-body"); advanced.append(content);
this.number(content, "padding", "Context padding", { min: 0, max: 2048 });
const masks = this.grid(content);
this.number(masks, "mask_blur", "Mask blur", { min: 0, max: 128, step: .5 }); this.number(masks, "mask_grow", "Mask grow / shrink", { min: -128, max: 128 });
const batch = this.grid(content); this.number(batch, "batch_size", "Batch size", { min: 1, max: 16 }); this.enum(batch, "resize_mode", "Resize mode");
this.toggle(content, "inpaint_model", "Use dedicated inpaint model"); this.toggle(content, "soft_edge", "Extra edge feathering");
this.number(content, "soft_strength", "Feather strength", { min: 0, max: 1, step: .05, range: true });
this.number(content, "refiner_switch", "Refiner switch ratio", { min: 0, max: 1, step: .01, range: true }); this.body.append(advanced);
}
destroy() { clearInterval(this.timer); this.observer.disconnect(); this.options.onTargetChange?.(this.target, null); this.target = null; this.root.remove(); }
}
+32
View File
@@ -0,0 +1,32 @@
// Remove obsolete feedback edges before LiteGraph assigns saved socket indices.
export function removeLegacyFeedback(graph, seen = new Set()) {
if (!graph || seen.has(graph)) return;
seen.add(graph);
const nodes = new Map((graph.nodes || []).map(node => [String(node.id), node]));
const removed = new Set(), indices = new Map();
for (const node of nodes.values()) {
if (node.type !== "AdachiInlineMask") continue;
const slots = (node.inputs || []).flatMap((input, index) => input.name === "result" ? [index] : []);
indices.set(String(node.id), slots);
for (const slot of slots) if (node.inputs[slot].link != null) removed.add(String(node.inputs[slot].link));
}
const entries = Array.isArray(graph.links) ? graph.links.map((link, index) => [index, link]) : Object.entries(graph.links || {});
for (const [, link] of entries) {
const id = Array.isArray(link) ? link[0] : link.id;
const target = String(Array.isArray(link) ? link[3] : link.target_id);
const slot = Array.isArray(link) ? link[4] : link.target_slot;
if (!indices.has(target)) continue;
const input = nodes.get(target).inputs?.[slot], slots = indices.get(target);
// Also repair orphaned feedback edges from a previously migrated save.
if (slots.includes(slot) || String(input?.link) !== String(id)) removed.add(String(id));
else if (Array.isArray(link)) link[4] -= slots.filter(index => index < slot).length;
else link.target_slot -= slots.filter(index => index < slot).length;
}
if (Array.isArray(graph.links)) graph.links = graph.links.filter(link => !removed.has(String(Array.isArray(link) ? link[0] : link.id)));
else for (const [key, link] of entries) if (removed.has(String(Array.isArray(link) ? link[0] : link.id))) delete graph.links[key];
for (const node of nodes.values()) {
if (node.type === "AdachiInlineMask") node.inputs = (node.inputs || []).filter(input => input.name !== "result");
for (const output of node.outputs || []) if (Array.isArray(output.links)) output.links = output.links.filter(id => !removed.has(String(id)));
}
for (const subgraph of graph.definitions?.subgraphs || []) removeLegacyFeedback(subgraph, seen);
}
+26
View File
@@ -0,0 +1,26 @@
// A pending upload owns the retry: new edits are checked once it settles.
// In particular, never poll a slow connection or an unfinished pointer stroke.
export function createPaintDraftProtection({paint,widget,graph,disposed,persist,fits,upload,changed,onError}) {
let timer=0, pending=false, stopped=false;
const schedule=()=>{
if(stopped)return;
clearTimeout(timer);
timer=setTimeout(run,0);
};
const run=async()=>{
timer=0;
if(stopped || disposed() || paint()?.drawing || pending)return;
const data=widget()?.value||'',revision=paint()?.revision;
try {
if(!data.startsWith('data:') || !persist() || fits(graph())!==false){changed();return;}
pending=true;
const name=await upload(data);
if(!stopped && !disposed() && widget()?.value===data && paint()?.revision===revision){widget().value=name;changed();}
} catch(error) {if(!stopped && !disposed())onError(error);}
finally {
pending=false;
if(!stopped && !disposed() && (widget()?.value!==data || paint()?.revision!==revision))schedule();
}
};
return {schedule,dispose(){stopped=true;clearTimeout(timer);}};
}
+95
View File
@@ -0,0 +1,95 @@
import { INPAINT_FIELDS } from "./inpaint_panel.js";
const hiddenWidgets = new WeakMap();
const targetSizes = new WeakMap();
function setHiddenOption(widget, value) {
widget.options ??= {};
// Recent frontends keep a reactive options proxy on BaseWidget state.
// Write through that proxy first so Nodes 2.0 observes runtime relocation.
const options = widget._state?.options || widget.options;
if (value === undefined) delete options.hidden; else options.hidden = value;
if (value === undefined) delete widget.options.hidden; else widget.options.hidden = value;
}
export function connectedEditor(target) {
const graph = target.graph; if (!graph) return null;
const input = target.inputs?.find(input => input.name === "image");
const link = graph.links?.[input?.link]; const queue = link ? [link.origin_id] : [], seen = new Set(), sources = new Set();
while (queue.length && seen.size < 256) {
const id = queue.shift(); if (seen.has(id)) continue; seen.add(id);
const current = graph.getNodeById(id); if (!current) continue;
if (current.type === "AdachiInlineMask") { sources.add(current.id); continue; }
for (const input of current.inputs || []) {
if (current.type === "AdachiImageCompare" && input.name !== "result") continue;
if (!["IMAGE", "*"].includes(input.type)) continue;
const next = graph.links?.[input.link]; if (next) queue.push(next.origin_id);
}
}
return sources.size === 1 ? graph.getNodeById([...sources][0]) : null;
}
export function connectedSamplers(source) {
return (source.graph?._nodes || [])
.filter(node => ["AdachiWebUIInpaint", "AdachiInpaintPrepare"].includes(node.type) && connectedEditor(node) === source)
.sort((a, b) => Number(b.type === "AdachiWebUIInpaint") - Number(a.type === "AdachiWebUIInpaint"));
}
// Use the same rounding, range and linked-input rules as Use source size.
export function syncCropDimensions(source, width, height, onlyTarget = null) {
const aligned = value => Math.max(64, Math.min(8192, Math.round(value / 8) * 8));
for (const target of connectedSamplers(source)) {
if (onlyTarget && target !== onlyTarget) continue;
target.adachiParameters?.refresh();
for (const [name, value] of [["width", width], ["height", height]]) {
if (target.inputs?.some(input => input.name === name && input.link != null)) continue;
const widget = target.widgets?.find(widget => widget.name === name);
if (widget && widget.value !== aligned(value)) {
widget.value = aligned(value);
widget.callback?.(widget.value);
}
}
target.adachiParameters?.refresh();
}
}
// The crop output does not have dimensions until the user locks a region.
export function syncCurrentDimensions(source, onlyTarget = null) {
const editor = source?.adachiEditor, crop = source?.adachiCrop;
if (!editor?.ready || !editor.image) return;
const region = crop?.mode === "crop" ? crop.region : null;
if (crop?.mode === "crop" && !region?.locked) return;
const image = region || editor.image;
syncCropDimensions(source, image.naturalWidth || image.width, image.naturalHeight || image.height, onlyTarget);
}
export function relocateWidgets(owner, previous, next) {
if (previous) {
for (const widget of previous.widgets || []) {
const state = hiddenWidgets.get(widget); if (!state) continue;
state.owners.delete(owner); if (state.owners.size) continue;
widget.type = state.type; widget.hidden = state.hidden;
setHiddenOption(widget, state.optionHidden);
if (state.ownComputeSize) widget.computeSize = state.computeSize; else delete widget.computeSize;
if (state.element) state.element.style.display = state.display;
hiddenWidgets.delete(widget);
}
const size = targetSizes.get(previous); if (size) { previous.setSize([previous.size[0], Math.max(size[1], previous.computeSize()[1])]); targetSizes.delete(previous); }
}
if (next) {
if (!targetSizes.has(next)) targetSizes.set(next, [...next.size]);
for (const widget of next.widgets || []) {
if (!INPAINT_FIELDS.includes(widget.name)) continue;
let state = hiddenWidgets.get(widget);
if (!state) {
const element = widget.element || widget.inputEl;
state = { type: widget.type, hidden: widget.hidden, optionHidden: widget.options?.hidden, ownComputeSize: Object.hasOwn(widget, "computeSize"), computeSize: widget.computeSize, element, display: element?.style.display || "", owners: new Set() };
hiddenWidgets.set(widget, state);
widget.type = "hidden"; widget.hidden = true; setHiddenOption(widget, true);
widget.computeSize = () => [0, -4]; if (element) element.style.display = "none";
}
state.owners.add(owner);
}
next.setSize([next.size[0], next.computeSize()[1]]);
}
}
+44
View File
@@ -0,0 +1,44 @@
// A failed half of a comparison must not leave its sibling decoding in the
// background, retain a corrupt blob forever, or leak a late object URL.
export async function decodePreview(blob, signal, {ImageClass=globalThis.Image, urls=URL, timeoutMs=20000}={}) {
signal?.throwIfAborted();
const url=urls.createObjectURL(blob), image=new ImageClass();
let timer, abort;
const stopped=new Promise((_,reject)=>{
abort=()=>{image.src='';reject(new DOMException('Cancelled','AbortError'));};
signal?.addEventListener('abort',abort,{once:true});
timer=setTimeout(()=>{image.src='';reject(Error('Preview image decoding timed out'));},timeoutMs);
});
try {
image.src=url;
await Promise.race([image.decode(),stopped]);
signal?.throwIfAborted();
const width=image.naturalWidth||image.width,height=image.naturalHeight||image.height;
if(!width || !height || width*height>32_000_000 || Math.max(width,height)>16384)throw Error('Preview image exceeds supported dimensions');
return {image,url};
} catch(error) {urls.revokeObjectURL(url);throw error;}
finally {clearTimeout(timer);signal?.removeEventListener('abort',abort);}
}
export async function loadPreviewPair(refs,{loadPreview,cached,signal,onProgress,onDecoding,decode=decodePreview,revoke=url=>URL.revokeObjectURL(url)}) {
signal.throwIfAborted();
const pair=new AbortController(),abort=()=>pair.abort();
signal.addEventListener('abort',abort,{once:true});
let failure;
try {
const results=await Promise.allSettled(refs.map(async(ref,index)=>{
try {
const blob=cached[index]||await loadPreview(ref,{signal:pair.signal,onProgress:state=>{if(!pair.signal.aborted)onProgress?.(index,state);}});
pair.signal.throwIfAborted();cached[index]=blob;
onDecoding?.();
try {return await decode(blob,pair.signal);}
catch(error){if(error.name!=='AbortError')cached[index]=null;throw error;}
} catch(error) {failure??=error;pair.abort();throw error;}
}));
if(failure || signal.aborted) {
for(const result of results)if(result.status==='fulfilled')revoke(result.value.url);
signal.throwIfAborted();throw failure;
}
return results.map(result=>result.value);
} finally {signal.removeEventListener('abort',abort);}
}
+51
View File
@@ -0,0 +1,51 @@
// Read through ComfyUI's authenticated API and report received bytes. The
// deadline measures inactivity, so a slow but advancing cloud download works.
export async function readPreview(fetchApi, path, {signal, onProgress, idleMs=30000, maxBytes=128*1024*1024}={}) {
const controller=new AbortController();let timer,timedOut=false;
const abort=()=>controller.abort();
const reset=()=>{clearTimeout(timer);timer=setTimeout(()=>{timedOut=true;controller.abort();},idleMs);};
if(signal?.aborted)controller.abort();else signal?.addEventListener('abort',abort,{once:true});
try {
controller.signal.throwIfAborted();
reset();
const response=await fetchApi(path,{signal:controller.signal,cache:'no-store'});
if(!response.ok)throw Object.assign(new Error(`Preview request failed (${response.status})`),{status:response.status});
const encoded=response.headers?.get('content-encoding');
const length=Number(response.headers?.get('content-length'));
const total=encoded && encoded!=='identity'?0:Number.isFinite(length)&&length>0?length:0;
if(total>maxBytes)throw Error('Preview exceeds the image size limit');
onProgress?.({loaded:0,total});
let blob;
if(response.body?.getReader) {
const reader=response.body.getReader(),chunks=[];let loaded=0;
const cancelRead=()=>{reader.cancel().catch(()=>{});};
controller.signal.addEventListener('abort',cancelRead,{once:true});
try {
while(true) {
controller.signal.throwIfAborted();
const {done,value}=await reader.read();
controller.signal.throwIfAborted();
if(done)break;
if(!value.byteLength)continue;
reset();loaded+=value.byteLength;
if(loaded>maxBytes)throw Error('Preview exceeds the image size limit');
chunks.push(value);onProgress?.({loaded,total});
}
if(total && loaded<total)throw Error('Preview download was interrupted');
blob=new Blob(chunks,{type:response.headers?.get('content-type')||'image/png'});
onProgress?.({loaded,total:loaded,complete:true});
} catch(error) {await reader.cancel().catch(()=>{});throw error;}
finally {controller.signal.removeEventListener('abort',cancelRead);reader.releaseLock();}
} else {
blob=await response.blob();
controller.signal.throwIfAborted();
if(blob.size>maxBytes)throw Error('Preview exceeds the image size limit');
onProgress?.({loaded:blob.size,total:blob.size,complete:true});
}
if(!blob.size)throw Error('Empty preview image');
return blob;
} catch(error) {
if(timedOut)throw Error('Preview download timed out');
throw error;
} finally {clearTimeout(timer);signal?.removeEventListener('abort',abort);controller.abort();}
}
+60
View File
@@ -0,0 +1,60 @@
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { executionId } from "./workspace_flow.js";
import { T } from "./i18n.js";
import {uploadImageWithProgress} from './upload_transport.js?v=stability1';
export function installResultAdoption(node, crop) {
// Saved workflows may still contain the old feedback socket and wire.
const removeLegacyInput = () => {
const index = node.inputs?.findIndex(input => input.name === "result");
if (index >= 0) node.removeInput(index);
};
removeLegacyInput();
const configured = node.onConfigure;
node.onConfigure = function () {
const result = configured?.apply(this, arguments);
removeLegacyInput();
return result;
};
node.adachiAdoptCrop = async (file, origin, {signal,onProgress}={}) => {
const editor = crop.editor, region = crop.region;
if (!editor.ready || !region.locked) throw Error(T("Select a crop region before adopting a result"));
if (crop.busy || editor.stroke) throw Error(T("Finish editing before adopting a result"));
if (origin && (origin.mode !== "crop" || String(origin.source) !== executionId(app.rootGraph||app.graph,node) || origin.job !== region.job)) {
throw Error(T("This result belongs to another crop; generate a result for the current region"));
}
if (file.size > 64 * 1024 * 1024) throw Error(T("File is too large (max 64 MB)"));
signal?.throwIfAborted();
const controller=crop.adoptController=new AbortController(),abort=()=>controller.abort();
signal?.addEventListener('abort',abort,{once:true});
crop.busy = true; crop.sync();
const objectUrl = URL.createObjectURL(file);
try {
const image = new Image(); image.src = objectUrl; await image.decode();
controller.signal.throwIfAborted();
if (image.width !== region.width || image.height !== region.height) {
throw Error(T("Result dimensions must match the selected crop"));
}
const body = new FormData();
body.append("image", file, `adopted-${crypto.randomUUID()}.png`);
body.append("type", "input"); body.append("subfolder", "adachi-inpaint-layers");
const uploaded = await uploadImageWithProgress(api,body,onProgress,{signal:controller.signal});
if (typeof uploaded.name !== "string" || !uploaded.name) throw Error(T("Upload failed"));
if (editor.destroyed || crop.mode !== "crop" || crop.region.job !== region.job) {
throw Error(T("The crop changed while adopting the result"));
}
const descriptor = {filename:uploaded.name, subfolder:uploaded.subfolder || "", type:"input"};
await crop.accept({job:region.job, image:descriptor}, {allowBusy:true, image});
const name = descriptor.subfolder ? `${descriptor.subfolder}/${descriptor.filename}` : descriptor.filename;
if (crop.widgets.layer_image.value !== name) {
// accept() may have skipped silently (busy / changed job / duplicate).
throw Error(T("The result was not applied; the crop or layer state changed"));
}
editor.status("");
} finally {
signal?.removeEventListener('abort',abort);if(crop.adoptController===controller)crop.adoptController=null;
URL.revokeObjectURL(objectUrl); crop.busy = false; crop.sync();
}
};
}
+93
View File
@@ -0,0 +1,93 @@
import {T} from './i18n.js';
// Execution notifications are an optimization, not the sole delivery channel.
// Poll only IDs queued by this browser; never guess from another workflow's
// latest history entry or from a coincidentally matching node ID.
import {executionNode,graphNodeEntries} from './workspace_flow.js';
export class ResultRecovery {
constructor({fetchApi,getGraph,onOutput,onPending=()=>{},onTerminal=()=>{},onDiscard=()=>{},interval=2500}) {
Object.assign(this,{fetchApi,getGraph,onOutput,onPending,onTerminal,onDiscard,interval});
this.pending=new Map();this.delivered=new Set();this.controllers=new Set();
}
track(graph,promptId,nodeId) {
const node=executionNode(graph,nodeId),key=`${promptId}:${nodeId}`;
if(this.destroyed || !promptId || node?.type!=='AdachiImageCompare' || this.delivered.has(key) || this.pending.has(key))return;
this.pending.set(key,{graph,node,promptId,nodeId:String(nodeId),key});
this.onPending(node);this.schedule(0);
}
schedule(ms=this.interval) {clearTimeout(this.timer);if(!this.destroyed && this.pending.size)this.timer=setTimeout(()=>this.recover(),ms);}
discard(record){if(this.pending.delete(record.key))this.onDiscard(record.node);}
async recover() {
if(this.destroyed || this.busy)return;this.busy=true;clearTimeout(this.timer);
const groups=new Map();
for(const record of this.pending.values()) {
if(record.graph!==this.getGraph() || executionNode(record.graph,record.nodeId)!==record.node){this.discard(record);continue;}
const records=groups.get(record.promptId)||[];records.push(record);groups.set(record.promptId,records);
}
try {
for(const [promptId,records] of groups) {
if(this.destroyed)break;
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),15000);
this.controllers.add(controller);
try {
const response=await this.fetchApi(`/history/${encodeURIComponent(promptId)}`,{cache:'no-store',signal:controller.signal});
if(!response.ok)continue;
const entry=(await response.json())[promptId];if(!entry)continue;
for(const r of records) {
if(this.destroyed || !this.pending.has(r.key))continue;
if(r.graph!==this.getGraph() || executionNode(r.graph,r.nodeId)!==r.node){this.discard(r);continue;}
const output=entry.outputs?.[r.nodeId];
if(output?.adachi_originals?.length && output?.adachi_results?.length) {
await this.onOutput(r.node,output,promptId,entry);
this.pending.delete(r.key);this.delivered.add(r.key);
} else if(entry.status?.completed) {
this.pending.delete(r.key);this.onTerminal(r.node,entry,promptId);
}
}
} catch {} finally {clearTimeout(timer);this.controllers.delete(controller);controller.abort();}
}
} finally {
while(this.delivered.size>256)this.delivered.delete(this.delivered.values().next().value);
this.busy=false;this.schedule();
}
}
destroy(){this.destroyed=true;clearTimeout(this.timer);for(const controller of this.controllers)controller.abort();for(const record of this.pending.values())this.discard(record);this.delivered.clear();}
}
const installed=new WeakMap();
export function installComparisonRecovery(api,getGraph) {
if(installed.has(api))return installed.get(api);
const recovery=new ResultRecovery({fetchApi:api.fetchApi.bind(api),getGraph,
onDiscard:node=>{const view=node.adachiCompare;if(view){view.pendingOutputs=Math.max(0,(view.pendingOutputs||0)-1);view.updateButton();if(!view.pendingOutputs&&!view.loadingActive)view.progressBox.hidden=true;}},
onPending:node=>{const view=node.adachiCompare;if(view){view.pendingOutputs=(view.pendingOutputs||0)+1;view.updateButton();view.loading(view.options.waitingText||T("Waiting for the generated image…"));}},
onOutput(node,output,promptId,entry){
if(node.adachiCompare)node.adachiCompare.pendingOutputs=Math.max(0,(node.adachiCompare.pendingOutputs||0)-1);
const known=new Set((node.properties.adachi_compare?.results||[]).map(ref=>JSON.stringify([ref.type,ref.subfolder,ref.filename])));
if(output.adachi_results.some(ref=>!known.has(JSON.stringify([ref.type,ref.subfolder,ref.filename]))))node.onExecuted?.(output);
else if(node.adachiCompare?.ready)node.adachiCompare.progressBox.hidden=!(node.adachiCompare.pendingOutputs>0);
node.adachiCompare?.updateButton();
document.dispatchEvent(new CustomEvent('adachi-result-recovered',{detail:{node,promptId,entry}}));
},
onTerminal(node,entry,promptId){
const view=node.adachiCompare;if(view){view.pendingOutputs=Math.max(0,(view.pendingOutputs||0)-1);view.updateButton();view.progressBox.hidden=!(view.pendingOutputs>0);view.status(view.options.failedText||T("Generation failed; check the ComfyUI task history"),true);}
document.dispatchEvent(new CustomEvent('adachi-result-recovered',{detail:{node,promptId,entry}}));
},
});
const queued=api.queuePrompt;
api.queuePrompt=async function(...args) {
const graph=getGraph(),nodes=Object.entries(args[1]?.output||{}).filter(([,n])=>n.class_type==='AdachiImageCompare').map(([id])=>id);
const result=await queued.apply(this,args);
for(const id of nodes)recovery.track(graph,result.prompt_id,id);
return result;
};
const wake=()=>{
recovery.schedule(0);
for(const {node} of graphNodeEntries(getGraph())) {
const view=node.adachiCompare;
if(view && !view.ready && !view.loadingActive && view.results.length)view.loadSelected();
}
};
for(const event of ['reconnected','execution_success','execution_error','execution_interrupted'])api.addEventListener(event,()=>recovery.schedule(0));
api.addEventListener('reconnected',wake);window.addEventListener('online',wake);
document.addEventListener('visibilitychange',()=>{if(!document.hidden)wake();});
installed.set(api,recovery);return recovery;
}
+48
View File
@@ -0,0 +1,48 @@
import { T } from "./i18n.js";
// XMLHttpRequest reports bytes sent, unlike fetch. Keep ComfyUI's API URL and
// per-user header; the browser automatically sends same-origin session cookies.
export async function uploadImageWithProgress(api, body, onProgress, {signal,idleMs=60000}={}) {
const controller=new AbortController();let timer,timedOut=false;
const abort=()=>controller.abort();
const reset=(ms=idleMs)=>{clearTimeout(timer);timer=setTimeout(()=>{timedOut=true;controller.abort();},ms);};
if(signal?.aborted)controller.abort();else signal?.addEventListener('abort',abort,{once:true});
try {
controller.signal.throwIfAborted();reset();
const response = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let settled=false,lastLoaded=0;
const finish=(fn,value)=>{if(settled)return;settled=true;controller.signal.removeEventListener('abort',cancel);xhr.onload=xhr.onerror=xhr.onabort=null;xhr.upload.onprogress=xhr.upload.onload=null;fn(value);};
const cancel=()=>{xhr.abort();finish(reject,new DOMException(T('Upload was cancelled'),'AbortError'));};
controller.signal.addEventListener('abort',cancel,{once:true});
xhr.open("POST", api.apiURL("/upload/image"));
if (api.user) xhr.setRequestHeader("Comfy-User", api.user);
xhr.upload.onprogress = event => {if(event.loaded>lastLoaded){lastLoaded=event.loaded;reset();}onProgress?.({phase: "upload", loaded: event.loaded, total: event.lengthComputable ? event.total : 0});};
xhr.upload.onload = () => {reset();onProgress?.({phase: "processing"});};
xhr.onload = () => finish(resolve,xhr);
xhr.onerror = () => finish(reject,new Error(T("Network error while uploading image")));
xhr.onabort = () => finish(reject,new DOMException(T("Upload was cancelled"),'AbortError'));
try{xhr.send(body);}catch(error){finish(reject,error);}
});
// Some cloud frontends wrap fetchApi with an authentication mechanism that
// is not exposed to XHR. Retry rejected authentication through that same
// frontend API, without inventing upload percentages for the fallback.
if (response.status === 401 || response.status === 403) {
onProgress?.({phase: "authenticated"});
// fetch cannot expose upload byte progress. Give authenticated uploads
// a larger bounded budget rather than applying XHR's inactivity limit.
reset(idleMs*5);
const fallback = await api.fetchApi("/upload/image", {method: "POST", body, signal:controller.signal});
if (!fallback.ok) throw new Error(`${T("Upload failed")} (${fallback.status})`);
onProgress?.({phase: "processing"});
return await fallback.json();
}
if (response.status < 200 || response.status >= 300) throw new Error(`${T("Upload failed")} (${response.status})`);
try { return JSON.parse(response.responseText); }
catch { throw new Error(T("Invalid upload response")); }
} catch(error) {
if(timedOut)throw Error(T('Image upload timed out; check the connection and try again'));
throw error;
} finally {clearTimeout(timer);signal?.removeEventListener('abort',abort);controller.abort();}
}
+35
View File
@@ -0,0 +1,35 @@
/* Keep a continuous wheel gesture with its original viewport. */
const canvases = new Map();
let owner = null, lastTime = 0, modifiers = "";
const IDLE_MS = 180;
const reset = () => { owner = null; lastTime = 0; modifiers = ""; };
function capture(event) {
// Ignore synthetic events forwarded by ComfyUI; they are not new gestures.
if (!event.isTrusted) return;
const entry = canvases.get(event.target);
const rect = entry?.canvas.getBoundingClientRect();
const inside = rect && event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
const canvas = entry && inside && entry.ready() ? entry.canvas : null;
const now = performance.now(), keys = `${event.ctrlKey}:${event.altKey}:${event.metaKey}:${event.shiftKey}`;
if (!owner || now - lastTime > IDLE_MS || keys !== modifiers) owner = canvas || "graph";
else if (owner !== "graph" && owner !== canvas) owner = "graph";
lastTime = now; modifiers = keys;
if (!entry || !inside || !entry.ready()) return;
if (owner === canvas) entry.wheel(event);
else if (entry.graphWheel) {
event.preventDefault(); event.stopPropagation(); entry.graphWheel(event);
}
}
export function registerImageWheel(canvas, ready, wheel, graphWheel) {
if (!canvases.size) {
window.addEventListener("wheel", capture, { capture: true, passive: false });
window.addEventListener("blur", reset);
}
canvases.set(canvas, { canvas, ready, wheel, graphWheel });
return () => {
canvases.delete(canvas);
if (!canvases.size) { window.removeEventListener("wheel", capture, true); window.removeEventListener("blur", reset); reset(); }
};
}

Some files were not shown because too many files have changed in this diff Show More