Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93995804b7 | ||
|
|
a23e6c733c | ||
|
|
630f9e0cd1 | ||
|
|
47fa6cc3e9 | ||
|
|
c80ef8f551 | ||
|
|
02719c3a8d | ||
|
|
8d56704285 | ||
|
|
bc080ec1d9 | ||
|
|
8ec528161c | ||
|
|
f1d88c8e64 | ||
|
|
e908e5a8bb | ||
|
|
4a6f44787d | ||
|
|
4cb1be78ff | ||
|
|
1465bd8d7e | ||
|
|
392149ffa6 | ||
|
|
7c9c854846 | ||
|
|
08d2153d3b | ||
|
|
75e18a2558 | ||
|
|
880dfb518d |
+10
-13
@@ -16,6 +16,9 @@ jobs:
|
||||
- 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
|
||||
@@ -32,17 +35,11 @@ jobs:
|
||||
for source in pathlib.Path('web').glob('*.js'):
|
||||
subprocess.run(['node', '--input-type=module', '--check'], input=source.read_bytes(), check=True)
|
||||
PY
|
||||
- name: Check exclusive routing behavior
|
||||
run: node --experimental-default-type=module tests/test_exclusive_routes.mjs
|
||||
- name: Check comparison result history
|
||||
run: node --experimental-default-type=module tests/test_compare_history.mjs
|
||||
|
||||
- name: Check upload transport
|
||||
run: node --experimental-default-type=module tests/test_upload_transport.mjs
|
||||
|
||||
- name: Check crop geometry
|
||||
- name: Run all frontend regression tests
|
||||
run: |
|
||||
node --experimental-default-type=module tests/test_crop_geometry.mjs
|
||||
node --experimental-default-type=module tests/test_crop_dimensions.mjs
|
||||
node --experimental-default-type=module tests/test_legacy_feedback.mjs
|
||||
node --experimental-default-type=module tests/test_draft_guard.mjs
|
||||
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
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -28,7 +28,7 @@ Inpainting usually means moving between the workflow, a mask editor and the gene
|
||||
| Tool | What it adds |
|
||||
| --- | --- |
|
||||
| **Inline Mask** | Paint masks or crop, inpaint and blend two layers in the same node. |
|
||||
| **WebUI Inpaint** | Whole-image or masked-area sampling, familiar inpainting controls and compositing back at the source resolution. |
|
||||
| **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. |
|
||||
@@ -37,6 +37,22 @@ English and Simplified Chinese interfaces follow the ComfyUI language setting. T
|
||||
|
||||
## Features
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -46,7 +62,7 @@ While a source image uploads, a progress bar reports the bytes actually sent and
|
||||
- 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. Enable the pin (**Keep**) to keep the image fitted and centered; 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 **WebUI Inpaint** 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.
|
||||
- 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>
|
||||
@@ -68,11 +84,11 @@ In Crop & Blend mode, choose one of six 64-aligned presets spanning 512–2048 p
|
||||
|
||||
| Connection | Purpose |
|
||||
| --- | --- |
|
||||
| `Inline Mask.image` + `mask` → `WebUI Inpaint.image` + `mask` | Send the selected crop and its repaint mask. |
|
||||
| `Inline Mask.image` → `Image Compare.original`; `WebUI Inpaint.image` → `Image Compare.result` | Compare the crop before adopting it. |
|
||||
| `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. [Open the ready-wired example](examples/05-inline-crop-blend.json).
|
||||
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.
|
||||
|
||||
@@ -91,6 +107,8 @@ The panel includes mask inside/outside, original/fill/noise/zero-latent content,
|
||||
|
||||
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.
|
||||
@@ -109,7 +127,7 @@ Choose a preset on the left, configure **Enabled / Bypassed / Keep current** on
|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -125,7 +143,7 @@ Run from your `ComfyUI/custom_nodes` directory:
|
||||
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`, `WebUI Inpaint` or `Adachi` in the node menu.
|
||||
**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
|
||||
|
||||
@@ -143,14 +161,15 @@ For a Git installation, update with `git pull` inside the plugin folder, then re
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Drag [01-inline-inpaint.json](examples/01-inline-inpaint.json) into ComfyUI and select a compatible checkpoint, or connect your existing model, VAE and conditioning nodes.
|
||||
2. In **Inline Mask**, upload a picture and paint the region to change. **White repaints; black preserves.** Orange is only the on-screen overlay.
|
||||
3. Edit the upstream positive/negative prompts. Set the sampling area, width, height and denoise in **WebUI Inpaint**, then queue the workflow.
|
||||
4. Compare the output. Adopt a result when you want it to become the next source, and continue editing with the mask intact.
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Source[Inline Mask] -->|image + mask| Inpaint[WebUI Inpaint]
|
||||
Source[Inline Mask] -->|image + mask| Inpaint[Adachi Inpaint Workbench]
|
||||
Inputs[Model · VAE · Conditioning] --> Inpaint
|
||||
Source -->|original| Compare[Image Compare]
|
||||
Inpaint -->|result| Compare
|
||||
@@ -162,10 +181,10 @@ flowchart LR
|
||||
|
||||
| From | To |
|
||||
| --- | --- |
|
||||
| `Inline Mask.image` / `mask` | `WebUI Inpaint.image` / `mask` |
|
||||
| Your model / VAE / positive / negative conditioning | The matching inputs of `WebUI Inpaint` |
|
||||
| `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` |
|
||||
| `WebUI Inpaint.image` | `Image Compare.result` and/or `Save Image.images` |
|
||||
| `Adachi Inpaint Workbench.image` | `Image Compare.result` and/or `Save Image.images` |
|
||||
|
||||
The Refiner inputs are optional. Leave them disconnected unless you are using a compatible second-stage model.
|
||||
|
||||
@@ -176,14 +195,14 @@ All nodes live under **Adachi-Inpaint-Workbench**. The group preset controller i
|
||||
| Node | Purpose | Outputs |
|
||||
| --- | --- | --- |
|
||||
| **Inline Mask** | Mask painting or crop/inpaint/layer blending | `image`, `mask`, `full_image` |
|
||||
| **WebUI Inpaint** | Prepare, sample and composite in one node | `image` = full composite; `crop` = sampling canvas; `mask` = sampling mask |
|
||||
| **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` |
|
||||
|
||||
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`**. See the [modular example](examples/02-modular-inpaint.json).
|
||||
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`**.
|
||||
|
||||
## Canvas shortcuts
|
||||
|
||||
@@ -204,15 +223,9 @@ The painting panel also provides invert, fill, clear, PNG mask import/export, im
|
||||
|
||||
## Example workflows
|
||||
|
||||
Drag a JSON file into ComfyUI:
|
||||
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.
|
||||
|
||||
| Example | What to try |
|
||||
| --- | --- |
|
||||
| [01 · Inline inpainting](examples/01-inline-inpaint.json) | The complete painting and inpainting loop; select your own compatible checkpoint. |
|
||||
| [02 · Modular inpainting](examples/02-modular-inpaint.json) | Use standard encode/sample/decode nodes with Prepare and Composite. |
|
||||
| [03 · Group presets](examples/03-group-presets.json) | Model-free preset switching with image branches. |
|
||||
| [04 · Exclusive switch](examples/04-exclusive-switch.json) | Model-free A/B selection controlled by group presets. |
|
||||
| [05 · Inline crop and blend](examples/05-inline-crop-blend.json) | One editor, crop presets, comparison adoption and accepted full-image output; select your checkpoint and upload a source. |
|
||||
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.
|
||||
|
||||
## Compatibility and important behaviour
|
||||
|
||||
@@ -231,7 +244,7 @@ Drag a JSON file into ComfyUI:
|
||||
- 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 WebUI Inpaint; the modular Prepare node handles pixels and masks.
|
||||
- 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.
|
||||
|
||||
+38
-25
@@ -28,7 +28,7 @@
|
||||
| 工具 | 用途 |
|
||||
| --- | --- |
|
||||
| **内联蒙版 · Inline Mask** | 同一节点内涂蒙版,或裁切、重绘、双图层融合。 |
|
||||
| **WebUI 局部重绘 · WebUI Inpaint** | 整图或蒙版区域采样,使用熟悉的重绘参数,按原图尺寸回贴结果。 |
|
||||
| **安达重绘工作台 · Adachi Inpaint Workbench** | 整图或蒙版区域采样,使用熟悉的重绘参数,按原图尺寸回贴结果。 |
|
||||
| **图像对比 · Image Compare** | 鼠标划线对比、查看结果历史,一键采用后继续重绘。 |
|
||||
| **组忽略预设 · Group Bypass Presets** | 保存各组的打开/忽略组合,一键切换工作流配置。 |
|
||||
| **自动二选一 · Auto Exclusive Switch** | 自动输出唯一启用的分支,不用手动设置 switch。 |
|
||||
@@ -37,6 +37,22 @@
|
||||
|
||||
## 功能介绍
|
||||
|
||||
### Adachi Workbench · 黑白主题
|
||||
|
||||
在任意安达重绘工作台节点中点击「打开安达工作台」。工作流需包含本插件的内联蒙版、重绘和图像对比节点,但节点编号、标题、预设名称及模型文件名均不需与示例一致。页面沿所选重绘节点的实际连线寻找图像和结果;多个候选无法唯一判断时,在「工作流连接」中选择。已启用的组忽略预设节点提供预设按钮,缺失或存在多个控制器时会提醒。
|
||||
|
||||
模型、VAE、文本编码器和提示词从原生执行图读取,支持 Get/Set、Use Everywhere 广播及子图执行编号。模型采样设置、ControlNet、多编码器条件合并、图像处理和结果后处理会保留;可识别的组件显示编辑控件,其余自定义处理按原工作流执行,不按私人节点编号或模型家族名称重新接线。
|
||||
|
||||
新方案默认「沿用工作流 LoRA」,保留原有 LoRA 和正负 CLIP 分叉。取消勾选后使用自定义列表,空列表会禁用已识别的 LoRA;原工作流没有 LoRA 节点也可添加自定义 LoRA。Refiner 可在模型菜单选择,旧版已明确设置的方案仍会保留。
|
||||
|
||||
画布与配置之间的分隔条可以左右拖动,宽度会记住;双击恢复默认,方向键可微调。窄屏保持上下排列,右上角可切换并记住黑白主题。操作说明放在悬停提示中。
|
||||
|
||||
生成时可以继续涂蒙版、缩放、移动和调整参数,已提交任务使用当时的快照。工作流设置上方显示真实阶段和采样步数;结果默认滑动对比,前后图共用一个切换图标,点击「采用」才替换原图。裁切融合采用后返回编辑,保留选区与蒙版。下方可回退或选择之前的原图,历史限当前已加载的浏览器会话。
|
||||
|
||||
图像对比节点和网页按本次任务编号找回丢失的结果通知。图片加载显示真实接收字节、已知总大小时的百分比及解码状态;弱网失败自动重试,重连后可恢复,不产生预览文件。服务器重启或缓存淘汰造成的预览过期仍需重新运行。后端升级需重启 ComfyUI,仅前端改动强刷网页即可。
|
||||
|
||||
[官方模板](examples/Adachi-Workbench.json) 仅作为起点,不是必需的固定工作流。已验证不同编号和预设名的副本,以及完全新建的多链路工作流。界面默认英文,在 ComfyUI 设置中选择简体中文后显示中文,其他语言暂时回退英文。模型权重须与原工作流匹配;缺失文件会列出文件名和目标目录。
|
||||
|
||||
### 直接在工作流里画蒙版
|
||||
|
||||
在**内联蒙版**上传、拖入或粘贴原图,然后直接在节点画布上涂抹。普通蒙版模式上传图片时会自动填写已连接的局部重绘宽高;替换同尺寸图片时保留蒙版,尺寸不同则载入新图并重置蒙版。蒙版在浏览器内更新,随工作流保存,不需要每画一笔都保存、加载磁盘文件。
|
||||
@@ -46,7 +62,7 @@
|
||||
- 支持画笔软边、硬度、不透明度、数位笔压感、临时擦除和形状工具。
|
||||
- 顶部使用**↑ 上传、⛶ 适应、图钉**,右上角切换模式。图钉的悬停提示为「保持」:开启后图片持续适应并居中,普通滚轮控制工作流背景,`Ctrl` + 滚轮调整笔径,`Shift` + 滚轮调整硬度。
|
||||
- 支持横向、纵向调整编辑区大小;图钉状态和节点尺寸都能随工作流保存。
|
||||
- 连到 **WebUI 局部重绘**后,画笔工具和采样参数显示在重绘节点内,让图片编辑区保持简洁。画笔工具、蒙版内外和填充内容收进箭头折叠区,**撤销、重做、反转、全选、清空、导入蒙版、导出**整行始终显示;折叠状态随工作流保存。
|
||||
- 连到 **安达重绘工作台**后,画笔工具和采样参数显示在重绘节点内,让图片编辑区保持简洁。画笔工具、蒙版内外和填充内容收进箭头折叠区,**撤销、重做、反转、全选、清空、导入蒙版、导出**整行始终显示;折叠状态随工作流保存。
|
||||
- 放大工作流时限制显示画布的分配尺寸,并缓存原图/蒙版合成,减少反复重绘;实际保存的蒙版仍保持原图分辨率。
|
||||
|
||||
<details>
|
||||
@@ -68,11 +84,11 @@
|
||||
|
||||
| 连线 | 用途 |
|
||||
| --- | --- |
|
||||
| `内联蒙版.image`、`mask` → `WebUI 局部重绘.image`、`mask` | 把裁片和重绘蒙版送去生成。 |
|
||||
| `内联蒙版.image` → `图像对比.original`;`WebUI 局部重绘.image` → `图像对比.result` | 先对比裁片,再选择采用。 |
|
||||
| `内联蒙版.image`、`mask` → `安达重绘工作台.image`、`mask` | 把裁片和重绘蒙版送去生成。 |
|
||||
| `内联蒙版.image` → `图像对比.original`;`安达重绘工作台.image` → `图像对比.result` | 先对比裁片,再选择采用。 |
|
||||
| `内联蒙版.full_image` → `保存图像.images` | 保存包含**已采用图层**的完整图像。 |
|
||||
|
||||
先生成,在**图像对比**中查看,满意后点击**采用此结果**,才会嵌回当前裁切位置;没有回接输入线,也不会生成后自动替换。比较结果使用重绘节点的 **image** 输出;`crop` 是采样画布,尺寸可能不同。[已连好的示例](examples/05-inline-crop-blend.json)。
|
||||
先生成,在**图像对比**中查看,满意后点击**采用此结果**,才会嵌回当前裁切位置;没有回接输入线,也不会生成后自动替换。比较结果使用重绘节点的 **image** 输出;`crop` 是采样画布,尺寸可能不同。在[官方模板](examples/Adachi-Workbench.json)中选择 **Inpaint**,再将内联蒙版切换到**裁切融合**即可。
|
||||
|
||||
采用后结果显示在选区中,编辑器回到重绘蒙版,可直接继续生成。点击 **◐ 图层融合**可编辑融合蒙版:画笔保留新图,按住 **Alt** 擦回原图,软笔刷可融合接缝;融合蒙版与重绘蒙版相互独立。编辑融合蒙版时,**按住右键看原图,松开恢复工作区**。要选择新区域,请切回 **◌ 重绘蒙版**后右键,或按 Escape;当前可见结果会先合并,再取消选定,接着左键选择新区域。**↶ 撤销**可撤销当前蒙版的涂抹,鼠标悬停底部控件可查看说明。批量生成时,在图像对比中选中想要的结果再采用;换到其他选区后,带有旧选区标记的结果会拒绝回贴。
|
||||
|
||||
@@ -91,6 +107,8 @@
|
||||
|
||||
鼠标移入**图像对比**:**左边是重绘结果,右边是原图**;移开后显示完整结果。分隔线保持细线,图片持续适应编辑区,滚轮作用于工作流背景。
|
||||
|
||||
网络错误或加载超时时会自动重试,也可点击 **↻** 手动重新加载;如果临时预览已因服务器重启或缓存淘汰而失效,请重新运行工作流。
|
||||
|
||||
同一轮编辑中的结果会累计保存并默认选中最新一张,每张结果对应当时的原图;重复的预览通知不会重复计数。
|
||||
|
||||
点击 **↓** 可以按原始分辨率下载最新生成的结果,即使列表中正在查看旧图也会下载最新一张;批量生成时下载该批最后一张。
|
||||
@@ -109,7 +127,7 @@
|
||||
|
||||
将两路备选输入连接到**自动二选一**,忽略其中一路后,另一条有效分支自动流向共同输出,无需手动切换 `switch`。
|
||||
|
||||

|
||||

|
||||
|
||||
可用于提示词条件、字符串、图像、蒙版、模型、潜空间等数据;两路输入应为同一类型。两路都启用或都不可用时会报错,不会擅自优先选择 A。
|
||||
|
||||
@@ -125,7 +143,7 @@
|
||||
git clone https://github.com/adachi0130/Adachi-Inpaint-Workbench.git
|
||||
```
|
||||
|
||||
**完整重启 ComfyUI,再按 `Ctrl+F5` 强制刷新网页**,macOS 可用 `Cmd+Shift+R`。在节点菜单搜索 `Inline Mask`、`WebUI Inpaint` 或 `Adachi`。
|
||||
**完整重启 ComfyUI,再按 `Ctrl+F5` 强制刷新网页**,macOS 可用 `Cmd+Shift+R`。在节点菜单搜索 `Inline Mask`、`Adachi Inpaint Workbench` 或 `Adachi`。
|
||||
|
||||
### 其他安装方式
|
||||
|
||||
@@ -143,14 +161,15 @@ Git 安装用户可在插件目录运行 `git pull` 更新,然后重启 ComfyU
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 将 [01-inline-inpaint.json](examples/01-inline-inpaint.json) 拖入 ComfyUI,选择兼容的 checkpoint;也可以连接现有工作流的模型、VAE 和文本条件。
|
||||
2. 在**内联蒙版**上传图片,涂出需要修改的部分。**白色重绘、黑色保留**,橙色只是界面叠加显示。
|
||||
3. 编辑上游节点的正面/负面提示词,在 **WebUI 局部重绘**里设置采样区域、宽高和重绘幅度,运行工作流。
|
||||
4. 查看对比结果,满意时点击采用,将它作为新原图,保留蒙版继续修改。
|
||||
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[WebUI 局部重绘]
|
||||
Source[内联蒙版] -->|image + mask| Inpaint[安达重绘工作台]
|
||||
Inputs[模型 · VAE · 文本条件] --> Inpaint
|
||||
Source -->|original| Compare[图像对比]
|
||||
Inpaint -->|result| Compare
|
||||
@@ -162,10 +181,10 @@ flowchart LR
|
||||
|
||||
| 从哪里 | 连到哪里 |
|
||||
| --- | --- |
|
||||
| `内联蒙版.image` / `mask` | `WebUI 局部重绘.image` / `mask` |
|
||||
| 模型 / VAE / 正面条件 / 负面条件 | `WebUI 局部重绘`对应输入 |
|
||||
| `内联蒙版.image` / `mask` | `安达重绘工作台.image` / `mask` |
|
||||
| 模型 / VAE / 正面条件 / 负面条件 | `安达重绘工作台`对应输入 |
|
||||
| `内联蒙版.image` | `图像对比.original` |
|
||||
| `WebUI 局部重绘.image` | `图像对比.result`,以及需要时的 `保存图像.images` |
|
||||
| `安达重绘工作台.image` | `图像对比.result`,以及需要时的 `保存图像.images` |
|
||||
|
||||
Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接,否则留空即可。
|
||||
|
||||
@@ -176,14 +195,14 @@ Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接
|
||||
| 节点 | 用途 | 输出 |
|
||||
| --- | --- | --- |
|
||||
| **内联蒙版 · Inline Mask** | 普通蒙版或裁切重绘、双图层融合 | `image`、`mask`、`full_image` |
|
||||
| **WebUI 局部重绘 · WebUI Inpaint** | 一体化准备、采样、合成 | `image`:完整合成图;`crop`:采样画布;`mask`:采样蒙版 |
|
||||
| **安达重绘工作台 · 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`**,详见[模块化示例](examples/02-modular-inpaint.json)。
|
||||
模块化流程可使用 `Prepare.pixels → VAE 编码 → 设置潜空间噪声蒙版 → 采样器 → VAE 解码 → Composite`。`Prepare.mask` 连接噪声蒙版节点,**务必把 `Prepare.context` 连到 `Composite.context`**。
|
||||
|
||||
## 画布快捷操作
|
||||
|
||||
@@ -204,15 +223,9 @@ Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接
|
||||
|
||||
## 示例工作流
|
||||
|
||||
将对应 JSON 拖入 ComfyUI:
|
||||
只提供一个[官方模板:Adachi-Workbench.json](examples/Adachi-Workbench.json),包含生图与修图两条分支、共用模型、组忽略预设、条件/图像自动二选一、图像对比和保存。预设控制器位于受控组外;每次选择一个预设,同时打开两路会触发二选一的检查。
|
||||
|
||||
| 示例 | 内容 |
|
||||
| --- | --- |
|
||||
| [01 · 内联局部重绘](examples/01-inline-inpaint.json) | 完整绘制与重绘流程,需要自行选择兼容模型。 |
|
||||
| [02 · 模块化重绘](examples/02-modular-inpaint.json) | Prepare / Composite 配合常规编解码与采样节点。 |
|
||||
| [03 · 分组预设](examples/03-group-presets.json) | 使用图像分支演示预设切换,不需要模型。 |
|
||||
| [04 · 自动二选一](examples/04-exclusive-switch.json) | 分组预设控制两路输入,不需要模型。 |
|
||||
| [05 · 内联裁切融合](examples/05-inline-crop-blend.json) | 同一编辑器裁切、接收结果和输出完整回贴图,需要选择模型并上传原图。 |
|
||||
模板标题使用英文,提示词为重新编写的中性示例,模型为占位名称,需自行选择。修图前请先上传原图;裁切融合直接切换同一个内联蒙版即可。使用分离模型、文本编码器和 VAE 的模型家族时,请用匹配的加载节点替换 checkpoint 区块并连接对应输出。
|
||||
|
||||
## 兼容性与重要行为
|
||||
|
||||
|
||||
+14
-3
@@ -1,11 +1,13 @@
|
||||
# Git versions and rollback
|
||||
|
||||
The current version is **v1.0.0**. 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.
|
||||
The current version is **v2.0.0**. 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.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 |
|
||||
@@ -47,14 +49,23 @@ To undo only this update, run in the plugin folder:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git switch --detach local-before-v1.0.0
|
||||
git switch --detach v1.0.1
|
||||
```
|
||||
|
||||
Return to this version with:
|
||||
|
||||
```powershell
|
||||
git switch --detach v1.0.0
|
||||
git switch --detach v2.0.0
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Verification record
|
||||
|
||||
## 2.0.0 · 2026-11-09
|
||||
|
||||
- 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.
|
||||
@@ -97,3 +107,33 @@ References used during implementation:
|
||||
- [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.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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
|
||||
@@ -25,6 +26,7 @@ if not getattr(PromptServer.instance, "_adachi_preview_route", False):
|
||||
PromptServer.instance._adachi_preview_route = True
|
||||
|
||||
start_preview_cleanup()
|
||||
register_workspace_routes(PromptServer.instance)
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
|
||||
+2
-2
@@ -527,7 +527,7 @@ class AdachiWebUIInpaint:
|
||||
FUNCTION = "sample"
|
||||
CATEGORY = CATEGORY
|
||||
DESCRIPTION = "WebUI-style inpainting, crop, latent fill, sampling and original-size compositing."
|
||||
SEARCH_ALIASES = ["webui", "img2img", "inpaint", "局部重绘", "安达", "adachi"]
|
||||
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,
|
||||
@@ -1041,7 +1041,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AdachiInlineMask": "Inline Mask",
|
||||
"AdachiInpaintPrepare": "Inpaint Prepare",
|
||||
"AdachiInpaintComposite": "Inpaint Composite",
|
||||
"AdachiWebUIInpaint": "WebUI Inpaint",
|
||||
"AdachiWebUIInpaint": "Adachi Inpaint Workbench",
|
||||
"AdachiImageCompare": "Image Compare",
|
||||
"AdachiExclusiveSwitch": "Auto Exclusive Switch",
|
||||
"AdachiLayerCommit": "Internal Layer Commit",
|
||||
|
||||
@@ -1,694 +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": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_data",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "mask_data"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "source_data",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "source_data"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
7,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "AdachiInlineMask"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
780,
|
||||
80
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
130.65625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"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,
|
||||
1020
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"name": "refiner_model",
|
||||
"shape": 7,
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "refiner_positive",
|
||||
"shape": 7,
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "refiner_negative",
|
||||
"shape": 7,
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "area",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "area"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "padding",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "padding"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_blur",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "mask_blur"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_grow",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "mask_grow"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_mode",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "mask_mode"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "fill",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "fill"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "resize_mode",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "resize_mode"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "refiner_switch",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "refiner_switch"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "inpaint_model",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "inpaint_model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "soft_edge",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "soft_edge"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "soft_strength",
|
||||
"shape": 7,
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "soft_strength"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "crop",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"name": "original",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"type": "IMAGE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"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-Inpaint-Workbench\n1. Pick your own checkpoint\n2. Load an image in the left node and paint directly on it\n3. Select the brush and adjust inpainting parameters in the WebUI Inpaint node\n4. width/height is the sampling resolution; the final image is composited back at the source size\nTick \"Embed source in workflow\" in the inpaint node before sharing"
|
||||
],
|
||||
"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
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 6,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "EmptyImage",
|
||||
"pos": [
|
||||
-350,
|
||||
140
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "color"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
512,
|
||||
512,
|
||||
1,
|
||||
8096934
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageInvert",
|
||||
"pos": [
|
||||
120,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
225,
|
||||
48
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ImageInvert",
|
||||
"pos": [
|
||||
510,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
225,
|
||||
48
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
920,
|
||||
140
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
76
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Adachi/GroupPresetExample"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "AdachiGroupPresets",
|
||||
"pos": [
|
||||
80,
|
||||
440
|
||||
],
|
||||
"size": [
|
||||
620,
|
||||
410
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"adachi_group_presets": {
|
||||
"version": 1,
|
||||
"activeId": "01dbf1d1-c15a-4db1-97d5-9d522781c482",
|
||||
"presets": [
|
||||
{
|
||||
"id": "01dbf1d1-c15a-4db1-97d5-9d522781c482",
|
||||
"name": "All enabled",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "Base",
|
||||
"mode": 0
|
||||
},
|
||||
"group:2": {
|
||||
"title": "Detail",
|
||||
"mode": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fe43bc1f-1a1e-4f49-a37b-c6e6ca7cbc8a",
|
||||
"name": "Skip detail",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "Base",
|
||||
"mode": 0
|
||||
},
|
||||
"group:2": {
|
||||
"title": "Detail",
|
||||
"mode": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b881a666-4643-4af6-8feb-f1d02c47f113",
|
||||
"name": "Bypass both",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "Base",
|
||||
"mode": 4
|
||||
},
|
||||
"group:2": {
|
||||
"title": "Detail",
|
||||
"mode": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
820,
|
||||
550
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
180
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Adachi-Inpaint-Workbench - Group Bypass Presets\nSelect a preset on the left to switch a whole group between enabled and bypassed; no connections required\nAdjust each group's state on the right, or click \"Save current group states\" to store the current workflow\nRight-click the node -> Apply preset to switch quickly\nThis example only inverts an image, so no model is needed"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Base",
|
||||
"bounding": [
|
||||
80,
|
||||
100,
|
||||
300,
|
||||
230
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Detail",
|
||||
"bounding": [
|
||||
470,
|
||||
100,
|
||||
300,
|
||||
230
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "EmptyImage",
|
||||
"pos": [
|
||||
0,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
130
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "color"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
64,
|
||||
64,
|
||||
1,
|
||||
16711680
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "EmptyImage",
|
||||
"pos": [
|
||||
0,
|
||||
450
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
130
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "color"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
64,
|
||||
64,
|
||||
1,
|
||||
255
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ImageInvert",
|
||||
"pos": [
|
||||
300,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 4,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "ImageInvert",
|
||||
"pos": [
|
||||
300,
|
||||
450
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageInvert"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "AdachiExclusiveSwitch",
|
||||
"pos": [
|
||||
620,
|
||||
200
|
||||
],
|
||||
"size": [
|
||||
230,
|
||||
80
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input_a",
|
||||
"shape": 7,
|
||||
"type": "*",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "input_b",
|
||||
"shape": 7,
|
||||
"type": "*",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "output",
|
||||
"type": "*",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "AdachiExclusiveSwitch"
|
||||
},
|
||||
"widgets_values": [
|
||||
-1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
940,
|
||||
200
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"exclusive-test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "AdachiGroupPresets",
|
||||
"pos": [
|
||||
600,
|
||||
430
|
||||
],
|
||||
"size": [
|
||||
620,
|
||||
410
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"adachi_group_presets": {
|
||||
"version": 1,
|
||||
"activeId": "2908ca1a-7a3f-4eba-8203-524ab3f52d27",
|
||||
"presets": [
|
||||
{
|
||||
"id": "dd436f10-d4a4-46bc-aecb-fe185710f6e0",
|
||||
"name": "Use A only",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "A",
|
||||
"mode": 0
|
||||
},
|
||||
"group:2": {
|
||||
"title": "B",
|
||||
"mode": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2908ca1a-7a3f-4eba-8203-524ab3f52d27",
|
||||
"name": "Use B only",
|
||||
"groups": {
|
||||
"group:1": {
|
||||
"title": "A",
|
||||
"mode": 4
|
||||
},
|
||||
"group:2": {
|
||||
"title": "B",
|
||||
"mode": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
4,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "A",
|
||||
"bounding": [
|
||||
270,
|
||||
70,
|
||||
310,
|
||||
210
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "B",
|
||||
"bounding": [
|
||||
270,
|
||||
400,
|
||||
310,
|
||||
210
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8,
|
||||
"offset": [
|
||||
100,
|
||||
20
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,787 +0,0 @@
|
||||
{
|
||||
"id": "58f1c513-1df0-4f53-af63-69da48ad43f0",
|
||||
"revision": 0,
|
||||
"last_node_id": 8,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "AdachiInlineMask",
|
||||
"pos": [
|
||||
40,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
540,
|
||||
820
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_data",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "mask_data"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "source_data",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "source_data"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "edit_mode",
|
||||
"shape": 7,
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "edit_mode"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "crop_state",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "crop_state"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "blend_data",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "blend_data"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "layer_image",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "layer_image"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
7,
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "full_image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "AdachiInlineMask"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"crop",
|
||||
"{\"job\":\"8d759783-b3cd-4dbc-b4ea-2c8e307be653\"}",
|
||||
"",
|
||||
""
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"image": "",
|
||||
"mask_data": "",
|
||||
"source_data": "",
|
||||
"edit_mode": "crop",
|
||||
"crop_state": "{\"job\":\"8d759783-b3cd-4dbc-b4ea-2c8e307be653\"}",
|
||||
"blend_data": "",
|
||||
"layer_image": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
-300
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
130.65625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
"CHOOSE_YOUR_CHECKPOINT.safetensors"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"ckpt_name": "CHOOSE_YOUR_CHECKPOINT.safetensors"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
420,
|
||||
-350
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
150
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"text": "A detailed natural photograph, coherent lighting, high quality"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
800,
|
||||
-350
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
150
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "Negative",
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"blurry, distorted, artifacts"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"text": "blurry, distorted, artifacts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "AdachiWebUIInpaint",
|
||||
"pos": [
|
||||
700,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
430,
|
||||
767
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"name": "refiner_model",
|
||||
"shape": 7,
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "refiner_positive",
|
||||
"shape": 7,
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "refiner_negative",
|
||||
"shape": 7,
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "area",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "area"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "padding",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "padding"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_blur",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "mask_blur"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_grow",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "mask_grow"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "mask_mode",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "mask_mode"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "fill",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "fill"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "resize_mode",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "resize_mode"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "refiner_switch",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "refiner_switch"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "inpaint_model",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "inpaint_model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "soft_edge",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "soft_edge"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "soft_strength",
|
||||
"shape": 7,
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "soft_strength"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "crop",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"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
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"area": "only_masked",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"padding": 32,
|
||||
"mask_blur": 4,
|
||||
"mask_grow": 0,
|
||||
"mask_mode": "inside",
|
||||
"fill": "original",
|
||||
"resize_mode": "stretch",
|
||||
"seed": 42,
|
||||
"control_after_generate": "fixed",
|
||||
"steps": 20,
|
||||
"cfg": 7,
|
||||
"sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras",
|
||||
"denoise": 0.75,
|
||||
"batch_size": 1,
|
||||
"refiner_switch": 0.8,
|
||||
"inpaint_model": false,
|
||||
"soft_edge": false,
|
||||
"soft_strength": 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "AdachiImageCompare",
|
||||
"pos": [
|
||||
1300,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
590,
|
||||
620
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "original",
|
||||
"type": "IMAGE",
|
||||
"link": 15
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"type": "IMAGE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "AdachiImageCompare"
|
||||
},
|
||||
"widgets_values": [],
|
||||
"widgets_values_named": {}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1300,
|
||||
850
|
||||
],
|
||||
"size": [
|
||||
430,
|
||||
450
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Adachi/CropBlend"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"filename_prefix": "Adachi/CropBlend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
40,
|
||||
-190
|
||||
],
|
||||
"size": [
|
||||
540,
|
||||
260
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Inline Mask: Crop & Blend\n1. Select your checkpoint and upload the full source.\n2. Choose Crop & Blend and a size preset, then left-click to lock the black dashed frame. Inpaint width/height follow the crop automatically.\n3. Paint inside the empty crop mask and run the workflow.\n4. Inspect Image Compare and click Adopt this result. The patch returns to this crop; Inline Mask has no result input.\n5. Click the blend button to edit the blend mask: paint keeps new pixels and Alt restores original pixels. The repaint mask is retained.\n6. full_image is the current adopted full composite. In blend mode, hold right-click to preview the original. Switch to the repaint mask and right-click or press Escape to merge the visible result and release the selection, then left-click a new area. Hover the bottom controls for instructions."
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"text": "Inline Mask: Crop & Blend\n1. Select your checkpoint and upload the full source.\n2. Choose Crop & Blend and a size preset, then left-click to lock the black dashed frame. Inpaint width/height follow the crop automatically.\n3. Paint inside the empty crop mask and run the workflow.\n4. Inspect Image Compare and click Adopt this result. The patch returns to this crop; Inline Mask has no result input.\n5. Click the blend button to edit the blend mask: paint keeps new pixels and Alt restores original pixels. The repaint mask is retained.\n6. full_image is the current adopted full composite. Reselect merges it before choosing a new area."
|
||||
},
|
||||
"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"
|
||||
],
|
||||
[
|
||||
10,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
13,
|
||||
1,
|
||||
2,
|
||||
7,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
15,
|
||||
1,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.5,
|
||||
"offset": [
|
||||
0,
|
||||
400
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,18 @@
|
||||
"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": {
|
||||
@@ -22,6 +34,9 @@
|
||||
},
|
||||
"1": {
|
||||
"name": "mask"
|
||||
},
|
||||
"2": {
|
||||
"name": "full_image"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -114,7 +129,7 @@
|
||||
}
|
||||
},
|
||||
"AdachiWebUIInpaint": {
|
||||
"display_name": "WebUI Inpaint",
|
||||
"display_name": "Adachi Inpaint Workbench",
|
||||
"description": "WebUI-style inpainting, crop, latent fill, sampling and original-size compositing.",
|
||||
"inputs": {
|
||||
"image": {
|
||||
@@ -185,6 +200,10 @@
|
||||
"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."
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
}
|
||||
},
|
||||
"AdachiWebUIInpaint": {
|
||||
"display_name": "WebUI 局部重绘 / WebUI Inpaint",
|
||||
"display_name": "安达重绘工作台",
|
||||
"description": "WebUI 式局部重绘:整图/蒙版区域、填充、采样与原尺寸回贴。",
|
||||
"inputs": {
|
||||
"image": {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "adachi-inpaint-workbench"
|
||||
version = "1.0.0"
|
||||
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
|
||||
version = "2.0.0"
|
||||
description = "Inline mask painting, crop blending and a standalone inpainting workbench for ComfyUI"
|
||||
requires-python = ">=3.10"
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
|
||||
@@ -19,3 +19,7 @@ Per the [official publishing guide](https://docs.comfy.org/registry/publishing),
|
||||
|
||||
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.0 verification
|
||||
|
||||
After publishing 2.0.0, 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`.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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-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.
|
||||
@@ -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');
|
||||
@@ -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()
|
||||
@@ -0,0 +1,17 @@
|
||||
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');
|
||||
@@ -0,0 +1,22 @@
|
||||
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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -0,0 +1,67 @@
|
||||
"""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")
|
||||
|
||||
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")
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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"];
|
||||
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.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.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);
|
||||
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(),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");
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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;}
|
||||
+84
-24
@@ -6,12 +6,15 @@ const element = (tag, className, text) => {
|
||||
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(T("Preview unavailable; run the workflow again")));
|
||||
image.src = url;
|
||||
});
|
||||
const decodePreview = async (blob, signal) => {
|
||||
const url=URL.createObjectURL(blob), image=new Image();
|
||||
const abort=()=>{image.src="";};
|
||||
signal.addEventListener("abort",abort,{once:true});
|
||||
const timer=setTimeout(abort,20000);
|
||||
try { image.src=url;await image.decode();if(signal.aborted)throw new DOMException("Cancelled","AbortError");return {image,url}; }
|
||||
catch(error){URL.revokeObjectURL(url);throw error;}
|
||||
finally{clearTimeout(timer);signal.removeEventListener("abort",abort);}
|
||||
};
|
||||
|
||||
export class ImageCompareView {
|
||||
constructor(options) {
|
||||
@@ -27,6 +30,10 @@ export class ImageCompareView {
|
||||
this.resultSelect.onchange = () => { this.index = Number(this.resultSelect.value); this.options.onSelect?.(this.index); this.loadSelected(); };
|
||||
toolbar.append(this.resultSelect, this.dimensionsLabel);
|
||||
const spacer = element("span", "dd-spacer"); toolbar.append(spacer);
|
||||
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);
|
||||
@@ -44,6 +51,13 @@ export class ImageCompareView {
|
||||
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", T("Adopt into")));
|
||||
@@ -89,7 +103,7 @@ export class ImageCompareView {
|
||||
}
|
||||
|
||||
updateButton() {
|
||||
this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy;
|
||||
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;
|
||||
}
|
||||
@@ -126,27 +140,71 @@ 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}={}) {
|
||||
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})),decoded=[];
|
||||
this.loading(attempt?T("Retrying comparison images")+` (${attempt+1}/3)`:T("Loading comparison images"),states);
|
||||
const pair=new AbortController();const cancel=()=>pair.abort();controller.signal.addEventListener("abort",cancel,{once:true});
|
||||
try {
|
||||
images=await Promise.all([original,this.results[this.index]].map(async (ref,i)=>{
|
||||
const blob=blobs[i] || await this.options.loadPreview(ref,{signal:pair.signal,onProgress:state=>{states[i]=state;if(current() && !pair.signal.aborted)this.loading(T("Loading comparison images"),states);}});
|
||||
blobs[i]=blob;
|
||||
if(current() && states.every(s=>s.complete))this.loading(T("Decoding comparison images…"));
|
||||
if(!current())throw new DOMException("Cancelled","AbortError");
|
||||
const result=await decodePreview(blob,pair.signal);decoded.push(result);return result;
|
||||
}));
|
||||
break;
|
||||
} catch(error) {
|
||||
pair.abort();for(const item of decoded)URL.revokeObjectURL(item.url);
|
||||
if(!current())return;
|
||||
if(attempt===2)throw error;
|
||||
await new Promise(resolve=>{const timer=setTimeout(resolve,1000*(attempt+1));controller.signal.addEventListener("abort",()=>{clearTimeout(timer);resolve();},{once:true});});
|
||||
} finally {controller.signal.removeEventListener("abort",cancel);}
|
||||
}
|
||||
if(!current()){for(const item of images||[])URL.revokeObjectURL(item.url);return;}
|
||||
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.busy) return;
|
||||
if (!this.ready) { this.status(T("The result preview is still loading"), true); 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(T("Setting the result as the new source image…"));
|
||||
@@ -222,7 +280,9 @@ export class ImageCompareView {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true; ++this.version; cancelAnimationFrame(this.frame); this.observer.disconnect();
|
||||
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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+17
-16
@@ -1,6 +1,10 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { ImageCompareView } from "./compare.js?v=0.8.1";
|
||||
import { ImageCompareView } from "./compare.js?v=workspace-network2";
|
||||
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";
|
||||
@@ -14,32 +18,29 @@ function viewPath(image) {
|
||||
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 || T("Inline Mask")} #${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=>{const graph=app.rootGraph||app.graph;return 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; },
|
||||
@@ -49,7 +50,7 @@ app.registerExtension({
|
||||
return response.blob();
|
||||
},
|
||||
async onAdopt(id, image) {
|
||||
const target = node.graph?.getNodeById(id);
|
||||
const target = executionNode(app.rootGraph||app.graph,id);
|
||||
if (!target?.adachiUseResult) throw new Error(T("No inline mask node is available to receive the result"));
|
||||
const response = await api.fetchApi(viewPath(image));
|
||||
if (!response.ok) throw new Error(T("Result preview expired; run the workflow again"));
|
||||
|
||||
@@ -103,10 +103,12 @@
|
||||
.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; }
|
||||
@@ -145,3 +147,7 @@
|
||||
.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);}
|
||||
|
||||
+22
-5
@@ -19,7 +19,15 @@ class GroupPresetsPanel {
|
||||
this.refresh(true);
|
||||
});
|
||||
const toolbar = el("div", "dd-gp-toolbar"); toolbar.append(el("strong", "", T("Group presets")));
|
||||
this.add = this.button(toolbar, T("+ New preset"), () => this.showForm("add"));
|
||||
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"));
|
||||
@@ -28,9 +36,9 @@ class GroupPresetsPanel {
|
||||
this.button(this.form, T("Cancel"), () => { this.form.hidden = true; });
|
||||
this.form.onsubmit = event => { event.preventDefault(); this.submitName(); };
|
||||
this.nameInput.onkeydown = event => { event.stopPropagation(); if (event.key === "Escape") { event.preventDefault(); this.form.hidden = true; } };
|
||||
const body = el("div", "dd-gp-body");
|
||||
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 = el("section", "dd-gp-detail");
|
||||
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"));
|
||||
@@ -50,7 +58,16 @@ class GroupPresetsPanel {
|
||||
};
|
||||
window.addEventListener("wheel", this.onWheel, { capture: true, passive: false });
|
||||
this.timer = setInterval(() => { if (this.root.isConnected && !document.hidden) this.refresh(); }, 500);
|
||||
this.refresh(true);
|
||||
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) {
|
||||
@@ -122,7 +139,7 @@ app.registerExtension({
|
||||
widget.serialize = false; this.setSize([620, 410]);
|
||||
}
|
||||
onAdded() { this.adachiGroupPresets.refresh(true); }
|
||||
onConfigure() { this.adachiGroupPresets.model.restore(); 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;
|
||||
|
||||
+165
-18
@@ -6,6 +6,148 @@
|
||||
// buttons are drawn by this plugin, so they translate here by reading Comfy.Locale.
|
||||
|
||||
const ZH = {
|
||||
"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": "负面提示词",
|
||||
"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": "正在上传图片",
|
||||
"Uploading image…": "正在上传图片…",
|
||||
@@ -147,9 +289,9 @@ const ZH = {
|
||||
"The painting tools live in the other inpaint node connected to this image": "画笔工具位于连接此原图的另一个重绘节点",
|
||||
|
||||
// inpaint_panel.js
|
||||
"WebUI Inpaint": "WebUI 局部重绘",
|
||||
"Adachi Inpaint Workbench": "安达重绘工作台",
|
||||
"Controlled WebUI inpaint node": "控制的局部重绘节点",
|
||||
"Connect a WebUI inpaint node to edit its parameters here": "连接 WebUI 局部重绘节点后,在这里调整参数",
|
||||
"Connect a WebUI inpaint node to edit its parameters here": "连接安达重绘工作台后,在这里调整参数",
|
||||
"Mask mode": "重绘区域",
|
||||
"Fill content": "重绘参考内容",
|
||||
"Sampling area": "重绘画布",
|
||||
@@ -203,28 +345,33 @@ const ZH = {
|
||||
const DICTS = { zh: ZH };
|
||||
|
||||
function readLocale() {
|
||||
try {
|
||||
const api = globalThis.comfyAPI;
|
||||
const value = api?.app?.ui?.settings?.getSettingValue?.("Comfy.Locale");
|
||||
if (value) return String(value);
|
||||
} catch (_) { /* settings unavailable before the app boots */ }
|
||||
try {
|
||||
const value = globalThis.app?.ui?.settings?.getSettingValue?.("Comfy.Locale");
|
||||
if (value) return String(value);
|
||||
} catch (_) { /* ignore */ }
|
||||
// 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 DICTS[base] || null;
|
||||
const base = readLocale().split(/[-_]/)[0].toLowerCase();
|
||||
return Object.hasOwn(DICTS, base) ? DICTS[base] : null;
|
||||
}
|
||||
|
||||
// Translate a user-facing string. `context` disambiguates identical English strings
|
||||
// that need different translations (for example the Fill button and the Fill enum).
|
||||
export function T(text, context) {
|
||||
// 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();
|
||||
if (!dict) return text;
|
||||
const key = context ? `${context}:${text}` : text;
|
||||
return dict[key] ?? 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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { InlineMaskEditor } from "./editor.js?v=0.9.0";
|
||||
import { installInpaintWorkbench } from "./inpaint_node.js?v=0.8.2";
|
||||
import { installInpaintWorkbench } from "./inpaint_node.js?v=2.0.0";
|
||||
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
|
||||
import { routeWheelOutsideImage } from "./wheel_routing.js";
|
||||
import { InlineCropController } from "./inline_crop.js?v=0.8.2";
|
||||
@@ -13,7 +13,7 @@ import { uploadImageWithProgress } from "./upload_transport.js?v=0.9.0";
|
||||
const styleId = "adachi-inpaint-style";
|
||||
const stylesheet = document.getElementById(styleId) || document.createElement("link");
|
||||
stylesheet.id = styleId; stylesheet.rel = "stylesheet";
|
||||
stylesheet.href = new URL("./editor.css?v=0.9.0", import.meta.url).href;
|
||||
stylesheet.href = new URL("./editor.css?v=workspace-network2", import.meta.url).href;
|
||||
if (!stylesheet.isConnected) document.head.append(stylesheet);
|
||||
|
||||
function hide(widget) {
|
||||
@@ -105,7 +105,7 @@ app.registerExtension({
|
||||
const fitNodeToImage = () => {
|
||||
cancelAnimationFrame(fitFrame);
|
||||
fitFrame = requestAnimationFrame(() => {
|
||||
if (!editor.ready || editor.destroyed || !node.graph) return;
|
||||
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;
|
||||
|
||||
+20
-4
@@ -1,5 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { InpaintParameterPanel } from "./inpaint_panel.js?v=0.6.1";
|
||||
import { InpaintParameterPanel } from "./inpaint_panel.js?v=adachi-name";
|
||||
import { connectedEditor, connectedSamplers, relocateWidgets, syncCurrentDimensions } from "./parameter_bridge.js?v=0.8.2";
|
||||
import { routeWheelOutsideImage } from "./wheel_routing.js";
|
||||
import { T } from "./i18n.js";
|
||||
@@ -10,6 +10,13 @@ export function installInpaintWorkbench(nodeType) {
|
||||
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());
|
||||
@@ -43,11 +50,11 @@ export function installInpaintWorkbench(nodeType) {
|
||||
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 = () => fold.offsetHeight + actionHost.offsetHeight + (panel?.root.scrollHeight || 620);
|
||||
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) return;
|
||||
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);
|
||||
@@ -81,7 +88,16 @@ export function installInpaintWorkbench(nodeType) {
|
||||
bindWidgetToNodeWidth(node, widget, root);
|
||||
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); fold.open = !!node.properties.adachi_tools_open; queueMicrotask(() => { if (!removed) { panel.refresh(); scheduleSize(); } }); return result; };
|
||||
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);
|
||||
|
||||
@@ -13,7 +13,7 @@ const choices = {
|
||||
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("WebUI Inpaint")));
|
||||
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);
|
||||
@@ -31,7 +31,7 @@ export class InpaintParameterPanel {
|
||||
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("WebUI Inpaint")} #${n.id}`); option.value = String(n.id); this.targetSelect.append(option); }
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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 {
|
||||
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 total=encoded && encoded!=='identity'?0:Number(response.headers?.get('content-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;
|
||||
try {
|
||||
while(true) {
|
||||
const {done,value}=await reader.read();
|
||||
if(done)break;
|
||||
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 {reader.releaseLock();}
|
||||
} else {
|
||||
blob=await response.blob();
|
||||
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();}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { executionId } from "./workspace_flow.js";
|
||||
import { T } from "./i18n.js";
|
||||
|
||||
export function installResultAdoption(node, crop) {
|
||||
@@ -18,7 +20,7 @@ export function installResultAdoption(node, crop) {
|
||||
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) !== String(node.id) || origin.job !== region.job)) {
|
||||
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)"));
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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=()=>{},interval=2500}) {
|
||||
Object.assign(this,{fetchApi,getGraph,onOutput,onPending,onTerminal,interval});
|
||||
this.pending=new Map();this.delivered=new Set();
|
||||
}
|
||||
track(graph,promptId,nodeId) {
|
||||
const node=executionNode(graph,nodeId),key=`${promptId}:${nodeId}`;
|
||||
if(!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.pending.size)this.timer=setTimeout(()=>this.recover(),ms);}
|
||||
async recover() {
|
||||
if(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.pending.delete(record.key);continue;}
|
||||
const records=groups.get(record.promptId)||[];records.push(record);groups.set(record.promptId,records);
|
||||
}
|
||||
try {
|
||||
for(const [promptId,records] of groups) {
|
||||
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),15000);
|
||||
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(r.graph!==this.getGraph() || executionNode(r.graph,r.nodeId)!==r.node){this.pending.delete(r.key);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);}
|
||||
}
|
||||
} finally {
|
||||
while(this.delivered.size>256)this.delivered.delete(this.delivered.values().next().value);
|
||||
this.busy=false;this.schedule();
|
||||
}
|
||||
}
|
||||
destroy(){clearTimeout(this.timer);this.pending.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,
|
||||
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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { app } from "../../scripts/app.js";
|
||||
export function routeWheelOutsideImage(root, canvas, isReady) {
|
||||
const route = event => {
|
||||
if (!root.contains(event.target)) return;
|
||||
if (root.closest(".adachi-workspace")) return;
|
||||
if (event.target === canvas && isReady()) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
// Preserve the user's ComfyUI navigation mode (zoom or pan).
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Read-only discovery for the inpainting workbench. The live graph remains
|
||||
// authoritative: virtual Get/Set routes and group bypasses are resolved by
|
||||
// ComfyUI and its installed extensions when a prompt is actually queued.
|
||||
import {graphNodeEntries,executionNode} from './workspace_flow.js';
|
||||
export function inspectWorkbench(graph) {
|
||||
const nodes = graphNodeEntries(graph).map(entry=>entry.node);
|
||||
const find = type => nodes.filter(node => node.type === type);
|
||||
const editors = find("AdachiInlineMask");
|
||||
const samplers = find("AdachiWebUIInpaint");
|
||||
const comparisons = find("AdachiImageCompare");
|
||||
const presets = find("AdachiGroupPresets");
|
||||
const checkpoints = find("CheckpointLoaderSimple");
|
||||
const loras = nodes.filter(node => node.type === "LoraLoader" || node.type === "Power Lora Loader (rgthree)");
|
||||
const missing = [];
|
||||
if (!editors.length) missing.push("inline_mask");
|
||||
if (!samplers.length) missing.push("inpaint");
|
||||
if (!comparisons.length) missing.push("compare");
|
||||
return { editors, samplers, comparisons, presets, checkpoints, loras, missing };
|
||||
}
|
||||
|
||||
export function upstreamNodes(node,inputName) {
|
||||
const found=new Set(),queue=[];
|
||||
const addInput=(current,input)=>{const g=current.graph,l=g?.getLink?.(input?.link)??g?.links?.get?.(input?.link)??g?.links?.[input?.link];if(l)queue.push(g.getNodeById(l.origin_id));};
|
||||
for(const input of node?.inputs||[])if(!inputName||input.name===inputName)addInput(node,input);
|
||||
while(queue.length){const current=queue.shift();if(!current||found.has(current))continue;found.add(current);
|
||||
if(current.type==='GetNode'){try{queue.push(current.findSetter?.(current.graph));}catch{}}
|
||||
for(const input of current.inputs||[])addInput(current,input);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
export function selectWorkbenchPipeline(graph,preferred,options={}) {
|
||||
const nodes=inspectWorkbench(graph),choose=(list,explicit,candidates=[])=>{
|
||||
if(explicit&&list.includes(explicit))return {node:explicit,ambiguous:false};
|
||||
const pool=candidates.length?candidates:list,active=pool.filter(n=>n.mode===0);
|
||||
if(pool.length===1)return {node:pool[0],ambiguous:false};
|
||||
if(active.length===1)return {node:active[0],ambiguous:false};
|
||||
return {node:active[0]||pool[0],ambiguous:pool.length>1};
|
||||
};
|
||||
const sampler=choose(nodes.samplers,preferred||options.samplerNode);
|
||||
const saved=sampler.node?.properties?.adachi_workspace_connections;
|
||||
const editor=choose(nodes.editors,options.editorNode||executionNode(graph,saved?.editorId),nodes.editors.filter(n=>upstreamNodes(sampler.node,'image').has(n)));
|
||||
const compare=choose(nodes.comparisons,options.compareNode||executionNode(graph,saved?.compareId),nodes.comparisons.filter(n=>upstreamNodes(n,'result').has(sampler.node)));
|
||||
return {...nodes,editor:editor.node,sampler:sampler.node,compare:compare.node,ambiguous:sampler.ambiguous||editor.ambiguous||compare.ambiguous};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
.adachi-workspace {
|
||||
--aw-bg:#eef1f4; --aw-panel:#fff; --aw-input:#f6f8fa; --aw-text:#273747;
|
||||
--aw-muted:#61758a; --aw-line:#ced7e1; --aw-hover:#edf3f9;
|
||||
--aw-accent:#276da7; --aw-selected:#eaf3fb; --aw-selected-text:#205b8b;
|
||||
--aw-stage:#e9edf1; --aw-checker:#e0e5eb; --aw-error:#a93e34; --aw-ok:#237459;
|
||||
position:fixed; inset:0; z-index:12000; display:flex; flex-direction:column;
|
||||
background:var(--aw-bg); color:var(--aw-text); color-scheme:light;
|
||||
font:14px/1.5 'Segoe UI','Microsoft YaHei',sans-serif;
|
||||
}
|
||||
.adachi-workspace[data-theme=dark] {
|
||||
--aw-bg:#171a1e; --aw-panel:#24282e; --aw-input:#2d333b; --aw-text:#e3e8ef;
|
||||
--aw-muted:#a2b1c2; --aw-line:#46515e; --aw-hover:#343e49;
|
||||
--aw-accent:#357fb8; --aw-selected:#263f55; --aw-selected-text:#b4dcff;
|
||||
--aw-stage:#191d22; --aw-checker:#232931; --aw-error:#ffa69a; --aw-ok:#83d5b5;
|
||||
color-scheme:dark;
|
||||
}
|
||||
.adachi-workspace * {box-sizing:border-box}
|
||||
.adachi-workspace [hidden] {display:none!important}
|
||||
body.adachi-workspace-open > :not(.adachi-workspace):not(script):not(style):not(link) {opacity:0!important;pointer-events:none!important}
|
||||
.adachi-workspace button,.adachi-workspace select,.adachi-workspace input,.adachi-workspace textarea {
|
||||
font:inherit; color:var(--aw-text); background:var(--aw-panel); border:1px solid var(--aw-line);
|
||||
border-radius:5px; padding:7px 10px;
|
||||
}
|
||||
.adachi-workspace button {cursor:pointer;white-space:nowrap}
|
||||
.adachi-workspace button:hover {border-color:var(--aw-accent);background:var(--aw-hover)}
|
||||
.adachi-workspace button:disabled {opacity:.4;cursor:not-allowed}
|
||||
.adachi-workspace button:focus-visible,.adachi-workspace select:focus-visible {outline:2px solid var(--aw-accent);outline-offset:2px}
|
||||
.adachi-workspace button.active,.adachi-workspace button[aria-pressed=true],.adachi-workspace .dd-inpaint button.dd-active {
|
||||
background:var(--aw-selected);color:var(--aw-selected-text);border-color:var(--aw-accent);
|
||||
}
|
||||
.adachi-workspace button.primary {background:var(--aw-accent);border-color:var(--aw-accent);color:#fff}
|
||||
.adachi-workspace button.primary:hover {filter:brightness(1.1)}
|
||||
.adachi-workspace .aw-icon {display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:5px;flex-shrink:0}
|
||||
.adachi-workspace header {position:absolute;z-index:6;top:8px;left:20px;right:20px;height:36px;display:flex;align-items:center;gap:8px;pointer-events:none}
|
||||
.adachi-workspace header>* {pointer-events:auto}
|
||||
.adachi-workspace h1 {font-size:18px;font-weight:650;margin:0 auto 0 0;padding:3px 0;background:transparent;color:rgba(0,0,0,.62);white-space:nowrap}
|
||||
.adachi-workspace[data-theme=dark] h1 {text-shadow:0 0 1px rgba(255,255,255,.9),0 0 3px rgba(255,255,255,.65)}
|
||||
.adachi-workspace .aw-status {font-size:12px;min-width:0;max-width:42vw;flex:0 1 auto;padding:4px 8px;border-radius:4px;background:var(--aw-panel);color:var(--aw-muted);overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
||||
.adachi-workspace .aw-status[data-state=ok] {color:var(--aw-ok)}
|
||||
.adachi-workspace .aw-status[data-state=error] {color:var(--aw-error)}
|
||||
.adachi-workspace main {display:grid;grid-template-columns:minmax(0,1fr) 10px var(--aw-sidebar-width,360px);gap:0;padding:6px;flex:1;min-height:0}
|
||||
.adachi-workspace .aw-canvas {position:relative;min-height:0;overflow:hidden;border:1px solid var(--aw-line);border-radius:8px;background:var(--aw-panel)}
|
||||
.adachi-workspace .aw-surface {position:absolute;inset:0;min-height:0;background:var(--aw-stage)}
|
||||
.adachi-workspace .aw-toolbar {position:absolute;top:48px;left:8px;right:8px;z-index:5;display:flex;gap:6px;align-items:center;pointer-events:none}
|
||||
.adachi-workspace .aw-toolbar button {pointer-events:auto;padding:5px 9px}
|
||||
.adachi-workspace .aw-toolbar .aw-icon {padding:5px}
|
||||
.adachi-workspace .aw-toolbar .spacer {flex:1}
|
||||
.adachi-workspace .aw-footer {position:absolute;bottom:8px;left:8px;z-index:5;display:flex;align-items:center;flex-wrap:wrap;gap:6px;padding:5px;border:1px solid var(--aw-line);border-radius:5px;background:var(--aw-panel);max-width:calc(100% - 16px);font-size:12px;color:var(--aw-muted)}
|
||||
.adachi-workspace .aw-footer button {padding:4px 8px}
|
||||
.adachi-workspace .aw-footer select {max-width:260px;min-width:0;padding:4px 7px}
|
||||
.adachi-workspace .aw-note {display:none}
|
||||
.adachi-workspace .aw-result-image {width:100%;height:100%;position:absolute;object-fit:contain;background:var(--aw-stage)}
|
||||
.adachi-workspace aside {overflow:auto;background:var(--aw-panel);border:1px solid var(--aw-line);border-radius:8px;padding:48px 16px 16px}
|
||||
.adachi-workspace aside h2 {font-size:12px;letter-spacing:1px;color:var(--aw-muted);margin:0 0 12px}
|
||||
.adachi-workspace aside label {margin-top:10px}
|
||||
.adachi-workspace aside select,.adachi-workspace aside textarea {max-width:100%}
|
||||
.adachi-workspace aside textarea {width:100%;min-height:85px;resize:vertical}
|
||||
.adachi-workspace details {margin:8px 0}
|
||||
.adachi-workspace summary {cursor:pointer}
|
||||
.adachi-workspace .aw-presets {display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
|
||||
.adachi-workspace .aw-presets button {flex:1;padding:7px 5px;font-size:12px}
|
||||
.adachi-workspace .aw-profile-warning {border:1px solid var(--aw-line);border-left:3px solid var(--aw-accent);padding:8px;margin:0 0 12px;font-size:12px;color:var(--aw-muted)}
|
||||
.adachi-workspace .aw-bindings label {display:block;font-size:12px;color:var(--aw-muted)}
|
||||
.adachi-workspace .aw-bindings label select {width:100%;display:block;margin:5px 0 10px}
|
||||
.adachi-workspace .aw-bindings textarea {display:block;margin-top:5px;line-height:1.5}
|
||||
.adachi-workspace .aw-bindings label>button {font-size:12px;padding:1px 6px;margin-top:3px}
|
||||
.adachi-workspace .aw-bindings details {border-bottom:1px solid var(--aw-line);padding:5px 0 10px}
|
||||
.adachi-workspace .aw-bindings summary {font-size:12px;color:var(--aw-muted)}
|
||||
.adachi-workspace .aw-lora-row {display:grid;grid-template-columns:minmax(0,1fr) 65px 30px;gap:4px;margin:6px 0}
|
||||
.adachi-workspace .aw-lora-row input {min-width:0;width:100%;padding:5px}
|
||||
.adachi-workspace .aw-lora-row button {padding:3px}
|
||||
.adachi-workspace .aw-embedded {position:absolute!important;inset:0!important;width:100%!important;height:100%!important;max-height:none!important;transform:none!important;visibility:visible!important}
|
||||
.adachi-workspace .dd-inpaint {--dd-bg:var(--aw-panel);--dd-panel:var(--aw-input);--dd-line:var(--aw-line);--dd-text:var(--aw-text);--dd-muted:var(--aw-muted);--dd-accent:var(--aw-accent);color:var(--aw-text);color-scheme:inherit;background:var(--aw-panel)}
|
||||
.adachi-workspace .dd-inpaint button {background:var(--aw-input);color:var(--aw-text)}
|
||||
.adachi-workspace .dd-inpaint .dd-stage {background-color:var(--aw-stage);background-image:linear-gradient(45deg,var(--aw-checker) 25%,transparent 25%),linear-gradient(-45deg,var(--aw-checker) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,var(--aw-checker) 75%),linear-gradient(-45deg,transparent 75%,var(--aw-checker) 75%)}
|
||||
.adachi-workspace .dd-inpaint input,.adachi-workspace .dd-inpaint select {background:var(--aw-input);color:var(--aw-text)}
|
||||
.adachi-workspace .dd-inline-minimal {background:transparent}
|
||||
.adachi-workspace .dd-crop-bar {background:var(--aw-panel)}
|
||||
.adachi-workspace .dd-inpaint .dd-upload-progress {background:var(--aw-panel);color:var(--aw-text)}
|
||||
.adachi-workspace .aw-parameters {position:relative;min-height:100px}
|
||||
.adachi-workspace .aw-parameters>.dd-inpaint {position:relative!important;transform:none!important;inset:auto!important;height:auto!important;max-height:none!important;overflow:visible!important;width:100%!important}
|
||||
.adachi-workspace .dd-param-advanced-body {padding:8px 0}
|
||||
.adachi-workspace .dd-workbench {border:0}
|
||||
.adachi-workspace .dd-editor-controls {background:var(--aw-panel)}
|
||||
.adachi-workspace .dd-inpaint .dd-actions {padding:8px 0}
|
||||
.adachi-workspace .dd-inpaint .dd-settings {gap:6px;padding:6px 0}
|
||||
.adachi-workspace .dd-inpaint .dd-tools {padding:5px 0}
|
||||
.adachi-workspace .dd-inpaint .dd-tools button {padding:4px;font-size:11px}
|
||||
.adachi-workspace .dd-inpaint .dd-stage canvas {outline:none}
|
||||
.adachi-workspace .dd-inline-minimal>.dd-status:not([hidden]) {background:var(--aw-panel);color:var(--aw-error)}
|
||||
.adachi-workspace .dd-inpaint .dd-param-grid {gap:8px}
|
||||
.adachi-workspace .aw-canvas .dd-files,.adachi-workspace .aw-canvas .dd-mode-select {top:88px}
|
||||
.adachi-workspace .aw-canvas .dd-crop-bar {
|
||||
bottom:calc(var(--aw-history-height,40px) + 16px);left:8px;right:8px;
|
||||
background:var(--aw-panel);border:1px solid var(--aw-line);padding:5px;gap:5px;
|
||||
box-shadow:0 2px 8px #0002;
|
||||
}
|
||||
.adachi-workspace .aw-canvas .dd-crop-bar button,.adachi-workspace .aw-canvas .dd-crop-bar select,.adachi-workspace .aw-canvas .dd-crop-bar input {
|
||||
height:28px;min-height:28px;font-size:12px;color:var(--aw-text);
|
||||
}
|
||||
.adachi-workspace .aw-canvas .dd-files button {opacity:1}
|
||||
.adachi-workspace .aw-canvas .dd-adoption {display:none}
|
||||
.adachi-workspace .aw-canvas .dd-compare-stage {position:absolute;inset:0;min-height:0}
|
||||
.adachi-workspace .aw-canvas .dd-compare-toolbar {
|
||||
position:absolute;left:8px;right:8px;bottom:calc(var(--aw-history-height,40px) + 16px);z-index:4;
|
||||
padding:5px;border:1px solid var(--aw-line);border-radius:5px;background:var(--aw-panel);width:fit-content;max-width:calc(100% - 16px);
|
||||
}
|
||||
.adachi-workspace .aw-canvas .dd-status:not([hidden]) {
|
||||
position:absolute;left:8px;right:8px;z-index:5;bottom:calc(var(--aw-history-height,40px) + var(--aw-crop-height,32px) + 24px);
|
||||
padding:7px;border-radius:5px;background:var(--aw-panel);color:var(--aw-error);
|
||||
}
|
||||
.adachi-workspace .adachi-workspace-launch {display:none}
|
||||
@media(max-width:850px) {
|
||||
.adachi-workspace main {grid-template-columns:1fr;overflow:auto}
|
||||
.adachi-workspace .aw-canvas {min-height:78vh}
|
||||
.adachi-workspace aside {overflow:visible;padding-top:14px}
|
||||
.adachi-workspace header {left:12px;right:12px;height:36px;gap:5px}
|
||||
.adachi-workspace header h1 {font-size:14px}
|
||||
.adachi-workspace header button {padding:4px 6px;font-size:11px}
|
||||
.adachi-workspace header .aw-status {max-width:25vw;font-size:10px}
|
||||
}
|
||||
|
||||
.adachi-workspace .aw-canvas .dd-preview-progress {top:100px;bottom:auto;background:var(--aw-panel);color:var(--aw-text);border-color:var(--aw-line);}
|
||||
|
||||
.adachi-workspace .aw-generation-progress {padding:9px 10px;margin:0 0 14px;border:1px solid var(--aw-line);border-radius:5px;background:var(--aw-input);color:var(--aw-text);font-size:12px;overflow-wrap:anywhere;}
|
||||
.adachi-workspace .aw-generation-progress progress {display:block;width:100%;height:7px;margin:6px 0 0;accent-color:var(--aw-accent);}
|
||||
.adachi-workspace .aw-generation-progress[data-state=error] {color:var(--aw-error);}
|
||||
.adachi-workspace .aw-generation-progress[data-state=complete] {color:var(--aw-ok);}
|
||||
|
||||
.adachi-workspace .aw-generation-progress progress {appearance:none;border:0;border-radius:4px;overflow:hidden;background:var(--aw-line);}
|
||||
.adachi-workspace .aw-generation-progress progress::-webkit-progress-bar {background:var(--aw-line);border-radius:4px;}
|
||||
.adachi-workspace .aw-generation-progress progress::-webkit-progress-value {background:var(--aw-accent);border-radius:4px;}
|
||||
.adachi-workspace .aw-generation-progress progress::-moz-progress-bar {background:var(--aw-accent);border-radius:4px;}
|
||||
.adachi-workspace .aw-model-warning {padding:7px 9px;border:1px solid var(--aw-line);border-left:3px solid var(--aw-error);border-radius:4px;color:var(--aw-error);font-size:12px;}
|
||||
.adachi-workspace .aw-model-warning ul {margin:7px 0;padding-left:17px;overflow-wrap:anywhere;}
|
||||
.adachi-workspace .aw-model-warning li {margin:5px 0;}
|
||||
.adachi-workspace .aw-model-warning small {display:block;color:var(--aw-muted);}
|
||||
|
||||
@keyframes aw-progress-wait {from{background-position:100% 0}to{background-position:-100% 0}}
|
||||
.adachi-workspace .aw-generation-progress progress:not([value]) {background:linear-gradient(90deg,var(--aw-line) 20%,var(--aw-accent) 50%,var(--aw-line) 80%);background-size:200% 100%;animation:aw-progress-wait 1.5s linear infinite;}
|
||||
.adachi-workspace .aw-generation-progress progress:not([value])::-webkit-progress-bar,.adachi-workspace .aw-generation-progress progress:not([value])::-webkit-progress-value {background:transparent;}
|
||||
@media(prefers-reduced-motion:reduce){.adachi-workspace .aw-generation-progress progress:not([value]){animation:none}}
|
||||
|
||||
.adachi-workspace .aw-splitter {position:relative;cursor:col-resize;touch-action:none;outline:none;}
|
||||
.adachi-workspace .aw-splitter::before {content:"";position:absolute;top:12px;bottom:12px;left:4px;width:2px;background:var(--aw-line);border-radius:2px;}
|
||||
.adachi-workspace .aw-splitter:hover::before,.adachi-workspace .aw-splitter:focus-visible::before,.adachi-workspace[data-resizing] .aw-splitter::before {background:var(--aw-accent);}
|
||||
.adachi-workspace[data-resizing],.adachi-workspace[data-resizing] * {cursor:col-resize!important;user-select:none!important;}
|
||||
@media(max-width:850px){.adachi-workspace .aw-splitter{display:none}.adachi-workspace main{gap:8px}}
|
||||
|
||||
.adachi-workspace .aw-connections {font-size:12px;border-bottom:1px solid var(--aw-line);padding:3px 0 8px;margin:0 0 10px;}
|
||||
.adachi-workspace .aw-connections summary {color:var(--aw-muted);}
|
||||
.adachi-workspace .aw-connections label {display:block;margin-top:8px;color:var(--aw-muted);}
|
||||
.adachi-workspace .aw-connections select {display:block;width:100%;margin-top:4px;}
|
||||
.adachi-workspace .aw-connections button {padding:4px 8px;margin-top:8px;}
|
||||
.adachi-workspace .aw-connections p {color:var(--aw-error);}
|
||||
.adachi-workspace .aw-bindings details small {display:block;overflow-wrap:anywhere;}
|
||||
@@ -0,0 +1,27 @@
|
||||
const virtualTypes=new Set(['GetNode','SetNode','Reroute','AdachiExclusiveSwitch']);
|
||||
function members(group,seen=new Set()) {
|
||||
if(seen.has(group))return [];seen.add(group);group.recomputeInsideNodes?.();
|
||||
return [...new Set(Array.from(group._children||group._nodes||group.nodes||[]).flatMap(child=>
|
||||
typeof child.recomputeInsideNodes==='function'?members(child,seen):[child]))];
|
||||
}
|
||||
export function workspaceRoutingNodes(roots,controller) {
|
||||
const allowed=new Set();
|
||||
const plan=controller?.active&&controller.planStates?controller.planStates(controller.active).plan:null;
|
||||
for(const root of roots)if(root.mode===4){
|
||||
const groups=Array.from(root.graph?.groups||root.graph?._groups||[]).map(group=>{
|
||||
const nodes=members(group);return {nodes,area:Math.abs((group.size?.[0]||1)*(group.size?.[1]||1))};
|
||||
}).filter(group=>group.nodes.includes(root)).sort((a,b)=>a.area-b.area);
|
||||
const group=groups[0];if(!group)continue;
|
||||
const inherited=plan?.has(root)?plan.get(root)===4:group.nodes.filter(n=>!['AdachiGroupPresets','Note','MarkdownNote'].includes(n.type)).every(n=>n.mode===4);
|
||||
if(inherited)for(const node of group.nodes)allowed.add(node);
|
||||
}
|
||||
const found=new Set(),seen=new Set();
|
||||
const visit=node=>{
|
||||
if(!node||seen.has(node))return;seen.add(node);
|
||||
if(!roots.includes(node)&&!virtualTypes.has(node.type))return;
|
||||
if(virtualTypes.has(node.type)&&node.mode===4&&allowed.has(node))found.add(node);
|
||||
if(node.type==='GetNode'){const setter=node.findSetter?.(node.graph);if(setter)visit(setter);}
|
||||
for(const input of node.inputs||[]){const g=node.graph,l=g?.getLink?.(input.link)??g?.links?.get?.(input.link)??g?.links?.[input.link];if(l)visit(g.getNodeById(l.origin_id));}
|
||||
};
|
||||
roots.forEach(visit);return found;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import {T} from './i18n.js';
|
||||
import {inspectExecutionFlow,executionNode,executionId,graphNodeEntries} from './workspace_flow.js';
|
||||
// Bind the workbench to existing graph widgets; no model or LoRA is loaded here.
|
||||
const widget = (node, name) => node?.widgets?.find(w => w.name === name);
|
||||
const nodeList = graph => graphNodeEntries(graph).map(entry=>entry.node);
|
||||
const ref = (node, slot = 0) => [String(node.id), slot];
|
||||
const valueList = w => {
|
||||
const values = typeof w?.options?.values === 'function' ? w.options.values() : w?.options?.values;
|
||||
return Array.isArray(values) ? values.map(String) : [];
|
||||
};
|
||||
const stateKey = 'adachi_workspace_bindings';
|
||||
const presetController=(nodes,sampler,graph)=>{
|
||||
const saved=sampler.properties?.adachi_workspace_connections;
|
||||
if(saved && Object.hasOwn(saved,'controllerId'))return nodes.find(n=>n.type==='AdachiGroupPresets'&&n.mode===0&&executionId(graph,n)===saved.controllerId)?.adachiGroupPresets?.model;
|
||||
const active=nodes.filter(n=>n.type==='AdachiGroupPresets'&&n.mode===0),local=active.filter(n=>n.graph===sampler.graph),pool=local.length?local:active;
|
||||
return pool.length===1?pool[0].adachiGroupPresets?.model:undefined;
|
||||
};
|
||||
|
||||
export class WorkspaceBindings {
|
||||
constructor({graph, sampler, canvas, onChange = () => {}, markChanged = () => {}}) {
|
||||
Object.assign(this, {graph, sampler, canvas, onChange, markChanged});
|
||||
this.nodes = nodeList(graph);
|
||||
this.controller = presetController(this.nodes,sampler,graph);
|
||||
this.owner = this.controller?.node || sampler;
|
||||
this.owner.properties ??= {};
|
||||
this.owner.properties[stateKey] ??= {profiles:{}};
|
||||
this.catalog = {checkpoints:[], unets:[], ggufs:[], clipggufs:[], clips:[], vaes:[], loras:[]};
|
||||
this.catalogReady = new Set();
|
||||
this.profiles = this.controller?.data?.presets?.map(p => ({id:p.id, name:p.name, preset:p})) || [];
|
||||
if (!this.profiles.length) this.profiles = [{id:'current',name:T("Current workflow")}];
|
||||
this.profileId = this.controller?.data?.activeId || this.profiles[0].id;
|
||||
this.resolve();
|
||||
}
|
||||
resolve() {
|
||||
const profile=this.profiles.find(p=>p.id===this.profileId)||this.profiles[0];this.profileId=profile.id;
|
||||
const all=this.owner.properties[stateKey].profiles;
|
||||
if(!all[this.profileId])all[this.profileId]={loraMode:'inherit',loras:[],useRefiner:!!this.sampler.inputs?.find(i=>i.name==='refiner_model'&&i.link!=null)};
|
||||
this.settings=all[this.profileId];this.settings.loras??=[];
|
||||
// Retain explicit selections made in older versions without retaining
|
||||
// any of the old template IDs or model-family name matching.
|
||||
this.settings.loraMode??='override';
|
||||
this.settings.textOverrides??={};
|
||||
if(this.compiledProfileId===this.profileId)this.updateExecutionGraph(this.compiled,this.samplerId,false);
|
||||
else {this.flow=null;this.model=this.clip=this.vae=this.positive=this.negative=this.lora=null;this.promptSources={positive:T("Reading workflow connections"),negative:T("Reading workflow connections")};}
|
||||
return profile;
|
||||
}
|
||||
updateExecutionGraph(output,samplerId=executionId(this.graph,this.sampler),render=true) {
|
||||
this.compiled=output;this.compiledProfileId=this.profileId;this.samplerId=String(samplerId);
|
||||
this.flow=inspectExecutionFlow(output,this.samplerId,id=>executionNode(this.graph,id));
|
||||
this.model=this.flow.modelNodes[0]?.node||null;this.clip=this.flow.clipNodes[0]?.node||null;this.vae=this.flow.vaeNodes[0]?.node||null;
|
||||
this.positive=this.flow.encoders.positive.length===1?this.flow.encoders.positive[0].node:null;
|
||||
this.negative=this.flow.encoders.negative.length===1?this.flow.encoders.negative[0].node:null;
|
||||
this.lora=this.flow.loras[0]?.node||null;
|
||||
this.promptSources={positive:T("Actual sampler connections"),negative:T("Actual sampler connections")};
|
||||
const signature=JSON.stringify([this.profileId,this.flow.refs,[...this.flow.modelNodes,...this.flow.clipNodes,...this.flow.vaeNodes].map(e=>e.id),Object.entries(this.flow.encoders).map(([kind,items])=>[kind,items.map(e=>[e.id,e.fields])])]);
|
||||
// Do not replace a focused textarea for ordinary background validation.
|
||||
if(render && signature!==this.routingSignature){this.routingSignature=signature;this.render();}
|
||||
}
|
||||
changed() { this.markChanged(); this.onChange(this); }
|
||||
modelDependencies() {
|
||||
const specs={CheckpointLoader:['ckpt_name','checkpoints',T("Main model"),'checkpoints'],CheckpointLoaderSimple:['ckpt_name','checkpoints',T("Main model"),'checkpoints'],
|
||||
UNETLoader:['unet_name','unets',T("Main model"),'diffusion_models'],
|
||||
UNETLoaderGGUF:['unet_name','ggufs',T("Main model"),'diffusion_models'],
|
||||
CLIPLoader:['clip_name','clips',T("Text encoder"),'text_encoders'],
|
||||
CLIPLoaderGGUF:['clip_name','clipggufs',T("Text encoder"),'text_encoders'],
|
||||
VAELoader:['vae_name','vaes','VAE','vae']};
|
||||
const entries=this.flow?[...this.flow.modelNodes,...this.flow.clipNodes,...this.flow.vaeNodes]:[];
|
||||
const unique=entries.filter((e,i,all)=>all.findIndex(x=>x.id===e.id)===i);
|
||||
return unique.flatMap(entry=>{
|
||||
const node=entry.node;
|
||||
if(['DualCLIPLoader','TripleCLIPLoader','DualCLIPLoaderGGUF'].includes(node?.type))return [1,2,3].flatMap(index=>{const w=widget(node,'clip_name'+index);return w?[{...entry,widget:w,key:node.type.endsWith('GGUF')?'clipggufs':'clips',label:T("Text encoder {index}",{index}),folder:'text_encoders'}]:[];});
|
||||
const spec=specs[node?.type];if(!spec)return [];
|
||||
const [name,key,label,folder]=spec,w=widget(node,name);
|
||||
if(w)return [{...entry,widget:w,key,label,folder}];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
missingDependencies() {
|
||||
return this.modelDependencies().filter(d=>this.catalogReady.has(d.key) && d.widget.value && !this.catalog[d.key].includes(String(d.widget.value)));
|
||||
}
|
||||
refreshProfiles() {
|
||||
this.nodes=nodeList(this.graph);
|
||||
const controller = presetController(this.nodes,this.sampler,this.graph);
|
||||
const signature = JSON.stringify([controller?.node?.id, controller?.data?.activeId, controller?.data?.presets?.map(p=>[p.id,p.name])]);
|
||||
if(signature===this.presetSignature)return;
|
||||
this.presetSignature=signature;
|
||||
this.controller=controller;
|
||||
this.owner=controller?.node || this.sampler;
|
||||
this.owner.properties ??= {};
|
||||
this.owner.properties[stateKey] ??= {profiles:{}};
|
||||
this.profiles=controller?.data?.presets?.map(p=>({id:p.id,name:p.name,preset:p})) || [];
|
||||
if(!this.profiles.length)this.profiles=[{id:'current',name:T("Current workflow")}];
|
||||
this.profileId=controller?.data?.activeId || (this.profiles.some(p=>p.id===this.profileId)?this.profileId:this.profiles[0].id);
|
||||
this.resolve();this.render();this.onChange(this);
|
||||
}
|
||||
selectProfile(id) {
|
||||
if (!this.profiles.some(p=>p.id===id)) throw Error(T("The group preset no longer exists"));
|
||||
this.profileId=id;
|
||||
this.controller?.apply(id);
|
||||
this.resolve(); this.render(); this.changed();
|
||||
}
|
||||
getExecutionBinding() {
|
||||
const binding={samplerInputs:{},inputOverrides:{},removeInputs:{},
|
||||
loraMode:this.settings.loraMode,loras:this.settings.loras,clipConsumers:this.flow?.clipConsumers||[]};
|
||||
if(!this.flow)throw Error(T("Reading workflow connections; please wait"));
|
||||
// Final MODEL and CONDITIONING refs remain untouched, including custom
|
||||
// model patches, ControlNet and combinations of multiple encoders.
|
||||
for(const [kind,entries] of Object.entries(this.flow.encoders))for(const entry of entries)for(const field of entry.fields){
|
||||
const target=this.settings.textOverrides[entry.id]||{};
|
||||
const legacy=entries.length===1 && field==='text' && Object.hasOwn(this.settings,kind);
|
||||
if(Object.hasOwn(target,field)||legacy){binding.inputOverrides[entry.id]??={};binding.inputOverrides[entry.id][field]=Object.hasOwn(target,field)?target[field]:this.settings[kind];}
|
||||
}
|
||||
if(!this.settings.useRefiner || Number(widget(this.sampler,'refiner_switch')?.value??1)>=1)binding.removeInputs[this.samplerId]=['refiner_model','refiner_positive','refiner_negative'];
|
||||
return binding;
|
||||
}
|
||||
async loadCatalog(fetchApi) {
|
||||
const get = async path => {const r=await fetchApi(path);if(!r.ok)throw Error(T("Model list request failed ({status})",{status:r.status}));return r.json();};
|
||||
const sources=[['checkpoints','/object_info/CheckpointLoaderSimple','CheckpointLoaderSimple','ckpt_name'],
|
||||
['unets','/object_info/UNETLoader','UNETLoader','unet_name'],
|
||||
['clips','/object_info/CLIPLoader','CLIPLoader','clip_name'],
|
||||
['vaes','/object_info/VAELoader','VAELoader','vae_name'],['loras','/models/loras']];
|
||||
const results=await Promise.allSettled(sources.map(([,path])=>get(path)));
|
||||
if(this.destroyed)return;
|
||||
results.forEach((result,i)=>{if(result.status!=='fulfilled')return;const [key,,type,input]=sources[i];
|
||||
const values=type?result.value?.[type]?.input?.required?.[input]?.[0]:result.value;
|
||||
if(Array.isArray(values)){this.catalog[key]=values.map(String);this.catalogReady.add(key);}
|
||||
});
|
||||
this.catalogError=results.some(r=>r.status==='rejected')?T("Some model lists are unavailable; existing workflow selections remain usable"):'';
|
||||
this.render();
|
||||
}
|
||||
createPanel() { this.root=document.createElement('section');this.root.className='aw-bindings';this.render();return this.root; }
|
||||
render() {
|
||||
if(!this.root || this.destroyed)return;
|
||||
const doc=this.root.ownerDocument;
|
||||
const el=(tag,text)=>{const e=doc.createElement(tag);if(text)e.textContent=text;return e;};
|
||||
const row=(label,control)=>{const l=el('label',label);l.append(control);return l;};
|
||||
const select=(values,current,change)=>{const s=el('select');for(const [value,label] of values){const o=el('option',label);o.value=value;s.append(o);}s.value=current;s.onchange=()=>change(s.value);return s;};
|
||||
this.root.replaceChildren();
|
||||
const presets=el('div');presets.className='aw-presets';
|
||||
const controllerActive = this.controller && this.controller.node.mode === 0;
|
||||
if (!controllerActive) {const multiple=this.nodes.filter(n=>n.type==='AdachiGroupPresets'&&n.mode===0).length>1;const warning=el('p',multiple?T("Multiple group preset controllers found; choose one in Workflow connections."):T("No enabled Group Bypass Presets node; using the current workflow settings."));warning.className='aw-profile-warning';warning.setAttribute('role','status');this.root.append(warning);}
|
||||
for(const p of controllerActive ? this.profiles : []){const b=el('button',p.name);b.type='button';b.setAttribute('aria-pressed',String(p.id===this.profileId));b.onclick=()=>this.selectProfile(p.id);presets.append(b);}
|
||||
this.root.append(presets);
|
||||
const missing=this.missingDependencies();
|
||||
if(missing.length){const warning=el('details'),head=el('summary',T("Missing {count} model files",{count:missing.length}));warning.className='aw-model-warning';warning.title=missing.map(d=>T("{label}: {value}",{label:d.label,value:d.widget.value})).join('\n');warning.append(head);
|
||||
const list=el('ul');for(const d of missing){const item=el('li',T("{label}: {value}",{label:d.label,value:d.widget.value}));item.append(el('small',`ComfyUI/models/${d.folder}/`));list.append(item);}warning.append(list);this.root.append(warning);}
|
||||
const details=el('details'),summary=el('summary',T("Main model and LoRA"));details.append(summary);details.open=!!this.detailsOpen;details.ontoggle=()=>{this.detailsOpen=details.open;};
|
||||
const dependencies=this.modelDependencies();
|
||||
for(const d of dependencies){const w=d.widget,current=String(w.value||'');const values=[...new Set([...valueList(w),...this.catalog[d.key],current])].filter(Boolean);
|
||||
const label=dependencies.filter(x=>x.label===d.label).length>1?`${d.label} · ${d.node.title||d.node.type} #${d.id}`:d.label;const field=row(label,select(values.map(v=>[v,v]),current,v=>{w.value=v;w.callback?.(v,this.canvas,d.node);this.render();this.changed();}));field.title=`ComfyUI/models/${d.folder}/`;details.append(field);}
|
||||
if(!dependencies.some(d=>d.node===this.model))details.append(el('p',T("This profile uses the model from the workflow")));
|
||||
const loraTitle=el('div','LoRA');details.append(loraTitle);
|
||||
const inherit=el('input');inherit.type='checkbox';inherit.checked=this.settings.loraMode==='inherit';inherit.onchange=()=>{this.settings.loraMode=inherit.checked?'inherit':'override';this.render();this.changed();};const inheritRow=row(T("Inherit workflow LoRAs"),inherit);inheritRow.title=T("Keep existing LoRA nodes and both CLIP branches; uncheck to use the custom list below. An empty custom list disables recognized LoRAs.");details.append(inheritRow);
|
||||
if(inherit.checked){
|
||||
const active=[];for(const entry of this.flow?.loras||[]){const input=entry.api.inputs||{};if(entry.api.class_type==='Power Lora Loader (rgthree)'){for(const value of Object.values(input))if(value?.on && value.lora)active.push(`${value.lora} · ${value.strength??1}`);}else if(input.lora_name && (Number(input.strength_model)||Number(input.strength_clip)))active.push(`${input.lora_name} · ${input.strength_model??1}`);}
|
||||
const info=el('small',active.length?active.join(' / '):T("No active LoRAs in the original workflow"));info.title=active.join('\n');details.append(info);
|
||||
}
|
||||
const customLoras=el('div');customLoras.hidden=inherit.checked;details.append(customLoras);
|
||||
|
||||
if(!this.settings.loras.length)customLoras.append(el('small',T("No LoRA")));
|
||||
this.settings.loras.forEach((l,index)=>{
|
||||
const line=el('div');line.className='aw-lora-row';
|
||||
const names=[...new Set([...this.catalog.loras,l.name].filter(Boolean))];
|
||||
const pick=select([['',T("None")],...names.map(n=>[n,n])],l.name||'',v=>{l.name=v;this.changed();});pick.setAttribute('aria-label',T("LoRA {index}",{index:index+1}));
|
||||
const strength=el('input');strength.type='number';strength.min='-10';strength.max='10';strength.step='0.05';strength.value=l.strength??1;strength.setAttribute('aria-label',T("LoRA {index} strength",{index:index+1}));strength.onchange=()=>{const n=Number(strength.value);if(Number.isFinite(n)){l.strength=Math.max(-10,Math.min(10,n));strength.value=l.strength;this.changed();}};
|
||||
const remove=el('button','×');remove.type='button';remove.title=T("Remove this LoRA");remove.onclick=()=>{this.settings.loras.splice(index,1);this.render();this.changed();};
|
||||
line.append(pick,strength,remove);customLoras.append(line);
|
||||
});
|
||||
const add=el('button',T("+ Add LoRA"));add.type='button';add.disabled=!this.flow?.refs.model;add.onclick=()=>{this.settings.loras.push({name:'',strength:1});this.render();this.changed();};customLoras.append(add);
|
||||
const refiner=el('input');refiner.type='checkbox';refiner.checked=!!this.settings.useRefiner;
|
||||
refiner.onchange=()=>{this.settings.useRefiner=refiner.checked;this.changed();};
|
||||
details.append(row(T("Use the workflow Refiner (must be compatible with the main model)"),refiner));
|
||||
if(this.catalogError)details.append(el('small',this.catalogError));
|
||||
this.root.append(details);
|
||||
for(const [kind,label] of [['positive',T("Positive prompt")],['negative',T("Negative prompt")]]) {
|
||||
const entries=this.flow?.encoders[kind]||[];
|
||||
if(!entries.length){const info=el('p',label+": "+(this.flow?T("Controlled by the original workflow"):T("Reading connections…")));info.title=T("Encoders are identified from the execution graph; unrecognized conditioning stays controlled by the original workflow");this.root.append(info);continue;}
|
||||
const container=entries.length>1?el('details'):this.root;
|
||||
if(entries.length>1){container.append(el('summary',T("{label} · {count} encoders",{label,count:entries.length})));this.root.append(container);}
|
||||
for(const entry of entries)for(const name of entry.fields){
|
||||
const w=widget(entry.node,name),linked=Array.isArray(entry.api.inputs?.[name]);
|
||||
const saved=this.settings.textOverrides[entry.id]||{},legacy=entries.length===1&&name==='text'?this.settings[kind]:undefined;
|
||||
const text=el('textarea');text.rows=kind==='positive'?6:3;
|
||||
text.value=saved[name]??legacy??(linked?'':w?.value??entry.api.inputs?.[name]??'');
|
||||
text.placeholder=linked?T("Inherit upstream dynamic text; type to override"):'';
|
||||
text.dataset.executionId=entry.id;text.dataset.widget=name;
|
||||
text.oninput=()=>{this.settings.textOverrides[entry.id]??={};this.settings.textOverrides[entry.id][name]=text.value;this.changed();};
|
||||
const title=entries.length>1||entry.fields.length>1?`${label} · ${entry.node?.title||entry.api.class_type} · ${name}`:label;
|
||||
text.setAttribute('aria-label',title);const field=row(title,text);
|
||||
field.title=T("Actual sampler connections · {node} #{id} · {field}. {description}",{node:entry.node?.title||entry.api.class_type,id:entry.id,field:name,description:linked?T("Text comes from upstream; typing creates an override."):T("Changes affect only this encoder; downstream conditioning is preserved.")});
|
||||
const reset=el('button','↶');reset.type='button';reset.title=T("Restore {label} from the workflow",{label:title});reset.onclick=()=>{delete this.settings.textOverrides[entry.id]?.[name];if(entries.length===1&&name==='text')delete this.settings[kind];this.render();this.changed();};field.append(reset);container.append(field);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
destroy() {this.destroyed=true;this.root?.remove();}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {T} from './i18n.js';
|
||||
import {app} from '../../scripts/app.js';
|
||||
import {api} from '../../scripts/api.js';
|
||||
import {workspaceRequest} from './workspace_transport.js';
|
||||
import {workspaceRoutingNodes} from './workspace_activation.js';
|
||||
import {applyExecutionBinding,graphNodeEntries,executionId,executionNode} from './workspace_flow.js';
|
||||
const graphTasks=new WeakMap();
|
||||
const link=value=>Array.isArray(value)&&value.length===2&&typeof value[0]==='string'&&Number.isInteger(value[1]);
|
||||
function depends(prompt,ref,target,seen=new Set()) {
|
||||
if(!link(ref)||seen.has(ref[0]))return false;if(ref[0]===target)return true;seen.add(ref[0]);
|
||||
return Object.values(prompt[ref[0]]?.inputs||{}).some(input=>depends(prompt,input,target,seen));
|
||||
}
|
||||
export function prepareWorkspacePrompt(options) {
|
||||
const graph=app.rootGraph||app.graph;
|
||||
const previous=graphTasks.get(graph)||Promise.resolve();
|
||||
const task=previous.catch(()=>{}).then(()=>prepareSnapshot(graph,options));graphTasks.set(graph,task);return task;
|
||||
}
|
||||
async function prepareSnapshot(graph,{editorNode,samplerNode,compareNode,binding={},getBinding,allowEmpty=false,controller}) {
|
||||
if(!editorNode?.adachiEditor?.ready && !allowEmpty)throw Error(T("Upload a source image first"));
|
||||
const entries=graphNodeEntries(graph),roots=[editorNode,samplerNode,compareNode];
|
||||
if(graph!==(app.rootGraph||app.graph)||roots.some(n=>!entries.some(e=>e.node===n)))throw Error(T("The workflow changed; reopen the workbench"));
|
||||
const ids=roots.map(n=>executionId(graph,n)),[editorId,samplerId,compareId]=ids;
|
||||
const parents=roots.flatMap(node=>{const path=executionId(graph,node).split(':');return path.slice(0,-1).map((_,i)=>executionNode(graph,path.slice(0,i+1).join(':'))).filter(Boolean);});
|
||||
const routers=workspaceRoutingNodes(roots,controller),modes=new Map([...parents,...roots,...routers].map(n=>[n,n.mode]));
|
||||
if(editorNode.adachiEditor.ready)editorNode.adachiEditor.flush();let prepared;
|
||||
try {
|
||||
for(const node of modes.keys())node.mode=0;
|
||||
prepared=typeof app.ue_modified_prompt==='function'?await app.ue_modified_prompt():await app.graphToPrompt();
|
||||
if(!prepared?.output)throw Error(T("Workflow connections could not be resolved; check bypass states, Get/Set and broadcast connections"));
|
||||
} finally {for(const [node,mode]of modes)node.mode=mode;}
|
||||
if(graph!==(app.rootGraph||app.graph))throw Error(T("The workflow changed; check connections again"));
|
||||
const output=prepared.output,sampler=output[samplerId],compare=output[compareId];
|
||||
if(!sampler||!compare||!output[editorId])throw Error(T("The selected image, inpaint or comparison node is missing from the execution graph"));
|
||||
// Existing image processors and result postprocessing remain connected. An
|
||||
// unconnected workbench can use its explicitly selected inline source/output.
|
||||
if(!depends(output,sampler.inputs.image,editorId)){sampler.inputs.image=[editorId,0];sampler.inputs.mask=[editorId,1];}
|
||||
else if(!link(sampler.inputs.mask))sampler.inputs.mask=[editorId,1];
|
||||
if(!link(compare.inputs.original))compare.inputs.original=[editorId,0];
|
||||
if(!depends(output,compare.inputs.result,samplerId))compare.inputs.result=[samplerId,0];
|
||||
const resolved=getBinding?await getBinding(output,samplerId):binding;
|
||||
const prompt=applyExecutionBinding(output,samplerId,resolved);
|
||||
// Keep the editable workflow, including ignored groups and original routers,
|
||||
// in PNG metadata. Only the separate API execution graph is specialized.
|
||||
const workflow=JSON.parse(JSON.stringify(graph.serialize()));
|
||||
return {prompt,compare_id:compareId,client_id:api.clientId||'',extra_data:{extra_pnginfo:{workflow},auth_token_comfy_org:api.authToken,api_key_comfy_org:api.apiKey}};
|
||||
}
|
||||
export const validateWorkspace=prepared=>workspaceRequest(api,'validate',prepared);
|
||||
export const submitWorkspace=prepared=>workspaceRequest(api,'submit',prepared);
|
||||
@@ -0,0 +1,133 @@
|
||||
import {T} from './i18n.js';
|
||||
const link=value=>Array.isArray(value) && value.length===2 && ['string','number'].includes(typeof value[0]) && Number.isInteger(value[1]);
|
||||
const asRef=value=>link(value)?[String(value[0]),value[1]]:null;
|
||||
const loraTypes=new Set(['LoraLoader','LoraLoaderModelOnly','Power Lora Loader (rgthree)']);
|
||||
const staticRouter=api=>api.class_type==='AdachiExclusiveSwitch'||(['ComfySwitchNode','ComfySoftSwitchNode'].includes(api.class_type)&&typeof api.inputs?.switch==='boolean');
|
||||
const loaderTypes={MODEL:new Set(['CheckpointLoaderSimple','CheckpointLoader','UNETLoader','UNETLoaderGGUF','CheckpointLoaderNF4']),
|
||||
CLIP:new Set(['CheckpointLoaderSimple','CheckpointLoader','CLIPLoader','DualCLIPLoader','TripleCLIPLoader','CLIPLoaderGGUF','DualCLIPLoaderGGUF']),
|
||||
VAE:new Set(['CheckpointLoaderSimple','CheckpointLoader','VAELoader'])};
|
||||
|
||||
function sourceInputs(api) {
|
||||
if(['ComfySwitchNode','ComfySoftSwitchNode'].includes(api.class_type)&&typeof api.inputs?.switch==='boolean'){
|
||||
const inputs=api.inputs;if(api.class_type==='ComfySoftSwitchNode'){
|
||||
if(!Object.hasOwn(inputs,'on_false'))return [inputs.on_true];if(!Object.hasOwn(inputs,'on_true'))return [inputs.on_false];
|
||||
}
|
||||
return [inputs[inputs.switch?'on_true':'on_false']];
|
||||
}
|
||||
if(api.class_type==='AdachiExclusiveSwitch') {
|
||||
const mask=Number(api.inputs?.active_inputs);
|
||||
if(mask===1)return [api.inputs.input_a];if(mask===2)return [api.inputs.input_b];
|
||||
}
|
||||
return Object.values(api.inputs||{});
|
||||
}
|
||||
function ancestors(prompt,refs) {
|
||||
const found=new Set(),queue=refs.map(asRef).filter(Boolean);
|
||||
while(queue.length){const [id]=queue.pop();if(found.has(id)||!prompt[id])continue;found.add(id);queue.push(...sourceInputs(prompt[id]).map(asRef).filter(Boolean));}
|
||||
return found;
|
||||
}
|
||||
function origins(prompt,start,type,lookup) {
|
||||
const found=[],seen=new Set(),queue=[asRef(start)].filter(Boolean);
|
||||
while(queue.length){const [id,slot]=queue.shift(),key=`${id}:${slot}`;if(seen.has(key))continue;seen.add(key);
|
||||
const api=prompt[id],node=lookup(id);if(!api)continue;
|
||||
if(loaderTypes[type].has(api.class_type)){found.push({id,node,api});continue;}
|
||||
if(staticRouter(api)){queue.push(...sourceInputs(api).map(asRef).filter(Boolean));continue;}
|
||||
if(loraTypes.has(api.class_type)){const ref=asRef(api.inputs?.[type==='MODEL'?'model':type==='CLIP'?'clip':'vae']);if(ref)queue.push(ref);continue;}
|
||||
const typed=(node?.inputs||[]).filter(i=>i.type===type).map(i=>i.name);
|
||||
const names=typed.length?typed:Object.keys(api.inputs||{}).filter(name=>new RegExp(`^${type.toLowerCase()}(?:$|[_\\d])`,'i').test(name));
|
||||
queue.push(...names.map(name=>asRef(api.inputs[name])).filter(Boolean));
|
||||
}
|
||||
return found.filter((item,i,all)=>all.findIndex(x=>x.id===item.id)===i);
|
||||
}
|
||||
function encoders(prompt,start,lookup) {
|
||||
const found=[],seen=new Set(),queue=[asRef(start)].filter(Boolean);
|
||||
while(queue.length){const [id,slot]=queue.shift(),key=`${id}:${slot}`;if(seen.has(key))continue;seen.add(key);
|
||||
const api=prompt[id],node=lookup(id);if(!api)continue;
|
||||
const fields=(node?.widgets||[]).filter(w=>/^(text(?:_[gl])?|prompt)$/i.test(w.name) && typeof w.value==='string').map(w=>w.name);
|
||||
const output=node?.outputs?.[slot];
|
||||
if(fields.length && (output?.type==='CONDITIONING'||/^CLIPTextEncode/.test(api.class_type))){found.push({id,node,api,fields});continue;}
|
||||
if(staticRouter(api)){queue.push(...sourceInputs(api).map(asRef).filter(Boolean));continue;}
|
||||
const selected=output?.name && /^(positive|negative)$/i.test(output.name) && asRef(api.inputs?.[output.name]);
|
||||
if(selected){queue.push(selected);continue;}
|
||||
const names=(node?.inputs||[]).filter(i=>i.type==='CONDITIONING').map(i=>i.name);
|
||||
const fallback=Object.keys(api.inputs||{}).filter(name=>/^(positive|negative|conditioning(?:_\d+)?)$/i.test(name));
|
||||
queue.push(...(names.length?names:fallback).map(name=>asRef(api.inputs[name])).filter(Boolean));
|
||||
}
|
||||
return found.filter((item,i,all)=>all.findIndex(x=>x.id===item.id)===i);
|
||||
}
|
||||
|
||||
export function inspectExecutionFlow(prompt,samplerId,lookup=()=>null) {
|
||||
const api=prompt[String(samplerId)],refs={};
|
||||
for(const name of ['model','positive','negative','vae'])refs[name]=asRef(api?.inputs?.[name]);
|
||||
const positive=encoders(prompt,refs.positive,lookup),negative=encoders(prompt,refs.negative,lookup);
|
||||
const allEncoders=[...positive,...negative].filter((item,i,all)=>all.findIndex(x=>x.id===item.id)===i);
|
||||
const consumers=allEncoders.filter(e=>asRef(e.api.inputs?.clip)).map(e=>({id:e.id,input:'clip'}));
|
||||
const clipNodes=consumers.flatMap(e=>origins(prompt,prompt[e.id].inputs[e.input],'CLIP',lookup)).filter((e,i,all)=>all.findIndex(x=>x.id===e.id)===i);
|
||||
return {refs,modelNodes:origins(prompt,refs.model,'MODEL',lookup),clipNodes,
|
||||
vaeNodes:origins(prompt,refs.vae,'VAE',lookup),encoders:{positive,negative},clipConsumers:consumers,
|
||||
loras:[...ancestors(prompt,Object.values(refs))].filter(id=>loraTypes.has(prompt[id].class_type)).map(id=>({id,node:lookup(id),api:prompt[id]}))};
|
||||
}
|
||||
|
||||
export function executionNode(graph,id) {
|
||||
const path=String(id).split(':');let node;
|
||||
for(let i=0;i<path.length;i++){node=graph?.getNodeById(path[i]);if(!node && /^-?\d+$/.test(path[i]))node=graph?.getNodeById(Number(path[i]));if(!node)return null;if(i<path.length-1)graph=node.subgraph;}
|
||||
return node;
|
||||
}
|
||||
|
||||
export function graphNodeEntries(graph,prefix=[],seen=new Set()) {
|
||||
if(!graph || seen.has(graph))return [];seen.add(graph);
|
||||
return Array.from(graph._nodes||[]).flatMap(node=>{
|
||||
const path=[...prefix,String(node.id)];
|
||||
return [{id:path.join(':'),node,graph},...graphNodeEntries(node.subgraph,path,seen)];
|
||||
});
|
||||
}
|
||||
export const executionId=(graph,node)=>graphNodeEntries(graph).find(entry=>entry.node===node)?.id;
|
||||
|
||||
// Rewrite only the chosen sampler's ancestry. Other output branches keep their
|
||||
// graph definitions; the backend subsequently queues only the comparison target.
|
||||
export function applyExecutionBinding(original,samplerId,binding={}) {
|
||||
// Native widget values can be Vue proxies (for example rgthree LoRA
|
||||
// rows). Copy using the same JSON boundary as ComfyUI's queue request.
|
||||
const prompt=JSON.parse(JSON.stringify(original)),id=String(samplerId),sampler=prompt[id];
|
||||
if(!sampler)throw Error(T("The selected inpaint node is missing from the execution graph"));
|
||||
Object.assign(sampler.inputs,binding.samplerInputs||{});
|
||||
for(const [key,inputs] of Object.entries(binding.inputOverrides||{})){
|
||||
if(!prompt[key])throw Error(T("The selected parameter node is missing from the execution graph #{id}",{id:key}));Object.assign(prompt[key].inputs,inputs);
|
||||
}
|
||||
for(const [key,names] of Object.entries(binding.removeInputs||{}))for(const name of names)if(prompt[key])delete prompt[key].inputs[name];
|
||||
const scope=()=>new Set([id,...ancestors(prompt,Object.values(sampler.inputs))]);
|
||||
const collapse=(candidates,choose)=>{
|
||||
const resolve=(value,seen=new Set())=>{
|
||||
const ref=asRef(value);if(!ref || !candidates.has(ref[0]))return ref;
|
||||
if(seen.has(ref[0]))return null;seen=new Set(seen);seen.add(ref[0]);
|
||||
const next=choose(prompt[ref[0]],ref[1]);return next?resolve(next,seen):null;
|
||||
};
|
||||
for(const key of scope())if(!candidates.has(key))for(const [name,value] of Object.entries(prompt[key]?.inputs||{})){
|
||||
const ref=asRef(value);if(ref && candidates.has(ref[0])){const result=resolve(ref);if(result && prompt[result[0]])prompt[key].inputs[name]=result;}
|
||||
}
|
||||
};
|
||||
const switches=new Set([...scope()].filter(key=>prompt[key]?.class_type==='AdachiExclusiveSwitch' && [1,2].includes(Number(prompt[key].inputs.active_inputs))));
|
||||
collapse(switches,(api,slot)=>slot===0?api.inputs[Number(api.inputs.active_inputs)===1?'input_a':'input_b']:null);
|
||||
if(binding.loraMode==='override') {
|
||||
const loras=new Set([...scope()].filter(key=>loraTypes.has(prompt[key]?.class_type)));
|
||||
collapse(loras,(api,slot)=>slot===0?api.inputs.model:slot===1?api.inputs.clip:null);
|
||||
const selected=(binding.loras||[]).filter(l=>l.name && l.on!==false);
|
||||
if(selected.length){
|
||||
const baseModel=asRef(sampler.inputs.model);if(!baseModel)throw Error(T("Connect a main model before adding LoRAs"));
|
||||
let nextId=-1000000;
|
||||
const fresh=()=>{while(prompt[String(nextId)])nextId--;return String(nextId--);};
|
||||
const consumers=(binding.clipConsumers||[]).filter(c=>asRef(prompt[c.id]?.inputs?.[c.input]));
|
||||
const unique=new Map();for(const c of consumers){const ref=asRef(prompt[c.id].inputs[c.input]),key=JSON.stringify(ref);if(!unique.has(key))unique.set(key,{ref,consumers:[]});unique.get(key).consumers.push(c);}
|
||||
const chains=unique.size?[...unique.values()]:[{ref:null,consumers:[]}];
|
||||
chains.forEach((chain,index)=>{
|
||||
let model=baseModel,clip=chain.ref;
|
||||
for(const l of selected){const key=fresh(),inputs={model,lora_name:l.name,strength_model:Number(l.strength??1)};
|
||||
if(clip)Object.assign(inputs,{clip,strength_clip:Number(l.clipStrength??l.strength??1)});
|
||||
prompt[key]={class_type:clip?'LoraLoader':'LoraLoaderModelOnly',inputs};model=[key,0];if(clip)clip=[key,1];
|
||||
}
|
||||
if(index===0)sampler.inputs.model=model;
|
||||
for(const c of chain.consumers)prompt[c.id].inputs[c.input]=clip;
|
||||
});
|
||||
}
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {T} from './i18n.js';
|
||||
// Session-only source checkpoints for the full-screen workspace. Capturing and
|
||||
// restoring never uploads a new image or serializes the rest of the workflow.
|
||||
const WIDGETS = ["image", "mask_data", "source_data", "edit_mode", "crop_state", "blend_data", "layer_image"];
|
||||
const copyCommands = commands => (commands || []).map(command => ({ ...command,
|
||||
...(command.points ? {points: command.points.map(point => ({...point}))} : {}),
|
||||
}));
|
||||
|
||||
function contextSnapshot(context) {
|
||||
return {
|
||||
mask: context.mask.toDataURL("image/png"),
|
||||
baseMask: context.baseMask.toDataURL("image/png"),
|
||||
history: copyCommands(context.history), future: copyCommands(context.future),
|
||||
lastMask: context.lastMask || "", dirty: !!context.dirty,
|
||||
};
|
||||
}
|
||||
|
||||
async function decodeCanvas(data) {
|
||||
const image = new Image(); image.src = data;
|
||||
await image.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth || image.width; canvas.height = image.naturalHeight || image.height;
|
||||
canvas.getContext("2d").drawImage(image, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function captureSourceState(node) {
|
||||
const editor = node.adachiEditor, crop = node.adachiCrop;
|
||||
if (!editor?.ready || !editor.image || editor.destroyed) throw Error(T("Load a source image first"));
|
||||
if (editor.stroke || editor.pointerId != null || crop?.busy) throw Error(T("Finish the current edit first"));
|
||||
editor.flush();
|
||||
const target = crop?.target || "mask", contexts = {};
|
||||
for (const [name, context] of Object.entries(crop?.contexts || {})) {
|
||||
if (name !== target && context?.mask && context?.baseMask) contexts[name] = contextSnapshot(context);
|
||||
}
|
||||
contexts[target] = contextSnapshot(editor);
|
||||
const state = {
|
||||
widgets: Object.fromEntries(WIDGETS.map(name => [name, node.widgets?.find(widget => widget.name === name)?.value])),
|
||||
image: editor.image, layer: crop?.layer || null, contexts, target,
|
||||
cropWidth: crop?.cropWidth, cropHeight: crop?.cropHeight,
|
||||
filename: editor.filename?.textContent || "", embed: !!editor.embed?.checked,
|
||||
view: editor.view, zoom: editor.zoom, pan: {...editor.pan}, keepFit: !!editor.keepFit,
|
||||
};
|
||||
state.key = JSON.stringify([state.widgets, state.image.src, state.layer?.src, state.contexts, state.target]);
|
||||
return state;
|
||||
}
|
||||
|
||||
function releaseContexts(contexts, active) {
|
||||
const canvases = new Set();
|
||||
for (const context of Object.values(contexts || {})) for (const name of ["mask", "baseMask"]) {
|
||||
if (context?.[name] && context[name] !== active?.mask && context[name] !== active?.baseMask) canvases.add(context[name]);
|
||||
}
|
||||
for (const canvas of canvases) canvas.width = canvas.height = 0;
|
||||
}
|
||||
|
||||
export async function restoreSourceState(node, state, {decode = decodeCanvas, onRestore} = {}) {
|
||||
const editor = node.adachiEditor, crop = node.adachiCrop;
|
||||
if (!editor || editor.destroyed || !node.graph) throw Error(T("The source image node was removed"));
|
||||
if (editor.stroke || editor.pointerId != null || crop?.busy) throw Error(T("Finish the current edit first"));
|
||||
const loadVersion = editor.loadVersion;
|
||||
const contexts = {};
|
||||
try {
|
||||
// Decode all masks before altering live state. A failed decode leaves
|
||||
// the working image, mask and crop completely untouched.
|
||||
for (const [name, context] of Object.entries(state.contexts)) {
|
||||
const [mask, baseMask] = await Promise.all([decode(context.mask), decode(context.baseMask)]);
|
||||
contexts[name] = {...context, mask, baseMask, history:copyCommands(context.history), future:copyCommands(context.future)};
|
||||
}
|
||||
if (editor.destroyed || editor.loadVersion !== loadVersion || editor.stroke || editor.pointerId != null || crop?.busy) {
|
||||
throw Error(T("The image changed during restoration; try again"));
|
||||
}
|
||||
} catch (error) { releaseContexts(contexts); throw error; }
|
||||
const active = contexts[state.target];
|
||||
if (!active) { releaseContexts(contexts); throw Error(T("The saved mask state is invalid")); }
|
||||
const oldContexts = {...(crop?.contexts || {}), live: {mask:editor.mask, baseMask:editor.baseMask}};
|
||||
crop?.stopOriginalPreview?.();
|
||||
for (const [name, value] of Object.entries(state.widgets)) {
|
||||
const widget = node.widgets?.find(widget => widget.name === name);
|
||||
if (widget && value !== undefined) widget.value = value;
|
||||
}
|
||||
editor.loadVersion++;
|
||||
editor.image = state.image;
|
||||
Object.assign(editor, active);
|
||||
editor.view = state.view; editor.zoom = state.zoom; editor.pan = {...state.pan};
|
||||
editor.keepFit = state.keepFit; editor.ready = true;
|
||||
editor.stroke = null; editor.pointerId = null; editor.drag = null; editor.cursor = null;
|
||||
editor.sceneKey = null; editor.maskRevision++;
|
||||
if (editor.filename) { editor.filename.textContent = state.filename; editor.filename.title = state.filename; }
|
||||
if (editor.sizeLabel) editor.sizeLabel.textContent = `${state.image.width} × ${state.image.height}`;
|
||||
if (editor.embed) editor.embed.checked = state.embed;
|
||||
node.properties ??= {}; node.properties.adachi_keep_fit = state.keepFit;
|
||||
if (editor.empty) editor.empty.hidden = true;
|
||||
if (crop) {
|
||||
clearTimeout(crop.resultTimer); crop.pendingResult = null; crop.acceptVersion++;
|
||||
crop.contexts = contexts; crop.target = state.target; crop.layer = state.layer;
|
||||
crop.cropWidth = state.cropWidth; crop.cropHeight = state.cropHeight;
|
||||
crop.hover = null; crop.revision++; crop.patchKey = null;
|
||||
crop.syncPreset?.(); crop.sync?.();
|
||||
}
|
||||
releaseContexts(oldContexts, active);
|
||||
editor.updateButtons?.(); editor.schedule?.();
|
||||
node.graph.setDirtyCanvas?.(true, true);
|
||||
await onRestore?.(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function estimateBytes(entries) {
|
||||
const images = new Set(); let bytes = 0;
|
||||
for (const {state} of entries) {
|
||||
bytes += state.key.length * 2;
|
||||
for (const image of [state.image, state.layer]) if (image && !images.has(image)) {
|
||||
images.add(image); bytes += image.width * image.height * 4;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export class WorkspaceHistory {
|
||||
constructor(node, {maxEntries = 12, maxBytes = 256 * 1024 * 1024, onChange, onRestore} = {}) {
|
||||
Object.assign(this, {node, maxEntries:Math.max(2,maxEntries), maxBytes, onChange, onRestore});
|
||||
this.entries = []; this.index = -1; this.busy = false;
|
||||
}
|
||||
get current() { return this.entries[this.index] || null; }
|
||||
get canUndo() { return this.index > 0 && !this.busy; }
|
||||
checkpoint(label = T("Source image")) {
|
||||
const state = captureSourceState(this.node);
|
||||
if (this.current?.state.key === state.key) return this.current;
|
||||
this.entries.splice(this.index + 1);
|
||||
const entry = {id:crypto.randomUUID(), label:String(label), state};
|
||||
this.entries.push(entry); this.index = this.entries.length - 1;
|
||||
// Keep the original and current source reachable; discard intermediate
|
||||
// checkpoints first when the session history reaches its budget.
|
||||
while (this.entries.length > 2 && (this.entries.length > this.maxEntries || estimateBytes(this.entries) > this.maxBytes)) {
|
||||
this.entries.splice(1,1); this.index--;
|
||||
}
|
||||
this.onChange?.(this); return entry;
|
||||
}
|
||||
async transact(label, operation) {
|
||||
if (this.busy) throw Error(T("Processing the source image; please wait"));
|
||||
this.busy = true; this.onChange?.(this);
|
||||
try {
|
||||
this.checkpoint(this.entries.length ? T("Edited source") : T("Initial source"));
|
||||
const result = await operation();
|
||||
this.checkpoint(label);
|
||||
return result;
|
||||
} finally { this.busy = false; this.onChange?.(this); }
|
||||
}
|
||||
async restore(index) {
|
||||
if (this.busy) throw Error(T("Processing the source image; please wait"));
|
||||
if (!Number.isInteger(index) || !this.entries[index]) throw Error(T("The source image history entry no longer exists"));
|
||||
this.busy = true; this.onChange?.(this);
|
||||
try {
|
||||
await restoreSourceState(this.node, this.entries[index].state, {onRestore:this.onRestore});
|
||||
this.index = index;
|
||||
return this.current;
|
||||
} finally { this.busy = false; this.onChange?.(this); }
|
||||
}
|
||||
undo() { return this.restore(this.index - 1); }
|
||||
original() { return this.restore(0); }
|
||||
clear() { this.entries = []; this.index = -1; this.onChange?.(this); }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {T} from './i18n.js';
|
||||
const stageNames={
|
||||
CheckpointLoaderSimple:"Loading main model",UNETLoader:"Loading main model",UNETLoaderGGUF:"Loading main model",
|
||||
CLIPLoader:"Loading text encoder",DualCLIPLoader:"Loading text encoder",VAELoader:"Loading VAE",
|
||||
CLIPTextEncode:"Encoding prompts",LoraLoader:"Loading LoRA",'Power Lora Loader (rgthree)':"Loading LoRA",
|
||||
AdachiInlineMask:"Reading source image and mask",AdachiWebUIInpaint:"Preparing inpaint sampling",
|
||||
KSampler:"KSampler",KSamplerAdvanced:"KSampler",SamplerCustom:"Sampler",
|
||||
VAEDecode:"VAE decode",VAEEncode:"VAE encode",AdachiImageCompare:"Preparing comparison images",
|
||||
};
|
||||
|
||||
// Percentages describe the current node's reported steps, not invented overall
|
||||
// job progress. Nodes without numeric progress use an indeterminate bar.
|
||||
export class WorkspaceProgress {
|
||||
constructor(onChange=()=>{}) {this.onChange=onChange;this.state='idle';this.buffer=[];}
|
||||
begin(nodes={},promptId=null) {
|
||||
this.nodes=nodes;this.promptId=promptId;this.state='queued';this.label=T("Queued…");
|
||||
this.nodeId=null;this.value=null;this.max=null;this.buffer=[];this.emit();
|
||||
}
|
||||
attach(promptId) {
|
||||
this.promptId=promptId;const events=this.buffer;this.buffer=[];
|
||||
for(const [name,data] of events)if(data?.prompt_id===promptId)this.handle(name,data);
|
||||
}
|
||||
emit(){this.onChange(this);}
|
||||
stage(id) {
|
||||
const node=this.nodes?.[String(id)],type=node?.class_type;
|
||||
return stageNames[type]?T(stageNames[type]):node?._meta?.title||type||T("Executing workflow");
|
||||
}
|
||||
finish(success,message) {
|
||||
this.state=success?'complete':'error';this.label=message||(success?T("Generation complete"):T("Generation stopped"));
|
||||
this.value=success?1:null;this.max=success?1:null;this.emit();
|
||||
}
|
||||
connection(connected) {
|
||||
if(!['queued','running'].includes(this.state))return;
|
||||
this.label=connected?T("Reconnected; waiting for execution progress…"):T("Connection lost; recovering execution progress…");this.value=this.max=null;this.emit();
|
||||
}
|
||||
handle(name,data={}) {
|
||||
// Older frontends reduce executing payloads to a scalar node ID and
|
||||
// dispatch null at completion, including before the submit HTTP reply.
|
||||
if(data==null)return false;
|
||||
if(name==='executing' && ['string','number'].includes(typeof data))data={node:String(data)};
|
||||
if(typeof data!=='object')return false;
|
||||
if(!['queued','running'].includes(this.state))return false;
|
||||
if(!this.promptId){this.buffer.push([name,data]);if(this.buffer.length>64)this.buffer.shift();return false;}
|
||||
if(data.prompt_id && data.prompt_id!==this.promptId)return false;
|
||||
if(name==='execution_success'){this.finish(true);return true;}
|
||||
if(['execution_error','execution_interrupted'].includes(name)){this.finish(false,data.exception_message||T("Generation stopped"));return true;}
|
||||
if(name==='execution_start'){this.state='running';this.label=T("Starting execution…");this.emit();return true;}
|
||||
if(name==='execution_cached'){
|
||||
if(this.state==='queued'){this.label=T("Reading cache · {count} nodes",{count:data.nodes?.length||0});this.emit();}return true;
|
||||
}
|
||||
if(name==='progress_state') {
|
||||
const running=Object.entries(data.nodes||{}).filter(([,n])=>n.state==='running');
|
||||
if(!running.length)return false;
|
||||
const [id,node]=running.at(-1);
|
||||
return this.handle('progress',{...node,prompt_id:this.promptId,node:id,
|
||||
max:Number(node.max)>1?node.max:0});
|
||||
}
|
||||
if(name==='executing' && data.node==null && data.node_id==null)return false;
|
||||
const id=data.node ?? data.node_id ?? this.nodeId;
|
||||
if(id==null || !this.nodes?.[String(id)])return false;
|
||||
const changed=String(id)!==this.nodeId;this.nodeId=String(id);this.state='running';
|
||||
if(changed || name==='executing'){this.value=this.max=null;}
|
||||
const max=Number(data.max),value=Number(data.value);
|
||||
if(name==='progress' && Number.isFinite(max) && max>0 && Number.isFinite(value)){
|
||||
this.max=max;this.value=Math.max(0,Math.min(max,value));
|
||||
const type=this.nodes[this.nodeId]?.class_type;
|
||||
this.label=`${type==='AdachiWebUIInpaint'?T("Inpaint sampling (KSampler)"):this.stage(id)} · ${this.value} / ${this.max}`;
|
||||
} else this.label=this.stage(id);
|
||||
this.emit();return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const linkAt = (graph, input) => input?.link == null ? null
|
||||
: graph.getLink?.(input.link) ?? graph.links?.get?.(input.link) ?? graph.links?.[input.link];
|
||||
// Follow the actual sampler conditioning inputs, including KJ Get/Set routes.
|
||||
// Ambiguous processing graphs are left under the workflow's control.
|
||||
export function conditioningSource(graph, sampler, inputName, modes = new Map()) {
|
||||
const mode = node => modes.get(node) ?? node.mode ?? 0;
|
||||
const visit = (node, slot, seen = new Set()) => {
|
||||
if (!node || seen.has(node) || mode(node) === 2) return null;
|
||||
seen = new Set(seen); seen.add(node);
|
||||
const follow = input => {
|
||||
const link = linkAt(node.graph || graph, input);
|
||||
return link ? visit((node.graph || graph).getNodeById(link.origin_id), link.origin_slot, seen) : null;
|
||||
};
|
||||
if (node.type === 'AdachiExclusiveSwitch') {
|
||||
const sources = (node.inputs || []).filter(i=>['input_a','input_b'].includes(i.name)).map(follow).filter(Boolean);
|
||||
return sources.length === 1 ? sources[0] : null;
|
||||
}
|
||||
if (node.type === 'GetNode') {
|
||||
if (mode(node) !== 0) return null;
|
||||
const name = node.widgets?.[0]?.value;
|
||||
const setter = node.findSetter?.(node.graph || graph) || Array.from(graph._nodes || []).find(n=>n.type === 'SetNode' && n.widgets?.[0]?.value === name);
|
||||
return setter ? visit(setter,slot,seen) : null;
|
||||
}
|
||||
if (node.type === 'SetNode' || node.type === 'Reroute') {
|
||||
if (mode(node) !== 0) return null;
|
||||
return follow(node.inputs?.[0]);
|
||||
}
|
||||
if (node.type === 'CLIPTextEncode') return mode(node) === 0 ? node : null;
|
||||
const resolved = node.resolveVirtualOutput?.(slot);
|
||||
if (resolved?.node) return visit(resolved.node,resolved.slot ?? 0,seen);
|
||||
return null;
|
||||
};
|
||||
const link = linkAt(graph, sampler.inputs?.find(i=>i.name===inputName));
|
||||
return link ? visit(graph.getNodeById(link.origin_id),link.origin_slot) : null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {T} from './i18n.js';
|
||||
export function installWorkspaceSplitter(root,main,divider) {
|
||||
const key='adachi-workspace-sidebar-width',minimum=280,canvasMinimum=320;
|
||||
let requested=360,drag=null;
|
||||
try {const saved=Number(localStorage.getItem(key));if(Number.isFinite(saved) && saved>=minimum)requested=saved;}catch{}
|
||||
const bounds=()=>{
|
||||
const style=getComputedStyle(main),width=main.clientWidth-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight);
|
||||
return {width,max:Math.max(minimum,Math.min(760,width-canvasMinimum-10))};
|
||||
};
|
||||
const render=()=>{
|
||||
const {width,max}=bounds(),actual=Math.min(max,Math.max(minimum,requested));
|
||||
root.style.setProperty('--aw-sidebar-width',`${actual}px`);
|
||||
divider.setAttribute('aria-valuemin',String(Math.min(canvasMinimum,width-minimum-10)));
|
||||
divider.setAttribute('aria-valuemax',String(Math.max(0,width-minimum-10)));
|
||||
divider.setAttribute('aria-valuenow',String(Math.max(0,width-actual-10)));
|
||||
divider.setAttribute('aria-valuetext',T("Settings panel {width} pixels wide",{width:Math.round(actual)}));
|
||||
return actual;
|
||||
};
|
||||
const save=()=>{try{localStorage.setItem(key,String(requested));}catch{}};
|
||||
const change=value=>{requested=Math.min(bounds().max,Math.max(minimum,value));render();};
|
||||
divider.setAttribute('role','separator');divider.setAttribute('aria-orientation','vertical');
|
||||
divider.setAttribute('aria-label',T("Resize canvas and settings"));divider.tabIndex=0;
|
||||
divider.title=T("Drag to resize the canvas and settings; double-click to reset, or use arrow keys for fine adjustments");
|
||||
divider.onpointerdown=e=>{
|
||||
if(e.button!==0)return;e.preventDefault();e.stopPropagation();
|
||||
drag={id:e.pointerId,x:e.clientX,width:render()};divider.setPointerCapture(e.pointerId);
|
||||
root.dataset.resizing='true';divider.focus({preventScroll:true});
|
||||
};
|
||||
divider.onpointermove=e=>{if(drag?.id!==e.pointerId)return;e.preventDefault();change(drag.width+drag.x-e.clientX);};
|
||||
const finish=e=>{
|
||||
if(drag?.id!==e.pointerId)return;drag=null;delete root.dataset.resizing;
|
||||
if(divider.hasPointerCapture(e.pointerId))divider.releasePointerCapture(e.pointerId);save();
|
||||
};
|
||||
divider.onpointerup=finish;divider.onpointercancel=finish;divider.onlostpointercapture=finish;
|
||||
divider.ondblclick=e=>{e.preventDefault();requested=360;render();save();};
|
||||
divider.onkeydown=e=>{
|
||||
const step=e.shiftKey?48:16;
|
||||
if(e.key==='ArrowLeft')change(render()+step);
|
||||
else if(e.key==='ArrowRight')change(render()-step);
|
||||
else if(e.key==='Home')change(bounds().max);
|
||||
else if(e.key==='End')change(minimum);
|
||||
else if(e.key==='Enter'){requested=360;render();}
|
||||
else return;
|
||||
e.preventDefault();e.stopPropagation();save();
|
||||
};
|
||||
const observer=new ResizeObserver(render);observer.observe(main);render();
|
||||
return ()=>{observer.disconnect();delete root.dataset.resizing;};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {T} from './i18n.js';
|
||||
// Check protocol support before POSTing: old ComfyUI processes may serve the
|
||||
// updated JavaScript while still running the previous Python node package.
|
||||
const unavailable = () => new Error(T("The workbench backend is not loaded. Stop and restart ComfyUI, then check connections again; refreshing the browser is not enough."));
|
||||
export async function workspaceRequest(api, path, prepared) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), path === 'submit' ? 60000 : 20000);
|
||||
try {
|
||||
let base;
|
||||
for (const candidate of ['/adachi-inpaint/workspace', '/api/adachi-inpaint/workspace']) {
|
||||
const probe = await api.fetchApi(`${candidate}/capabilities`, {signal:controller.signal});
|
||||
if (!probe.ok) continue;
|
||||
let info; try { info = await probe.json(); } catch { continue; }
|
||||
if (info.api_version === 1 && info.can_validate) { base = candidate; break; }
|
||||
}
|
||||
if (!base) throw unavailable();
|
||||
const response = await api.fetchApi(`${base}/${path}`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(prepared), signal:controller.signal,
|
||||
});
|
||||
if ([404,405].includes(response.status)) throw unavailable();
|
||||
let data;
|
||||
try { data = await response.json(); } catch { throw new Error(T("Invalid workbench response ({status}); check your proxy settings or restart ComfyUI",{status:response.status})); }
|
||||
if (!response.ok || !data.valid) {
|
||||
const errors = Object.entries(data.node_errors || {}).flatMap(([id,node]) =>
|
||||
(node.errors || []).map(e => {
|
||||
const labels={ckpt_name:T("Main model"),unet_name:T("Main model"),clip_name:T("Text encoder"),vae_name:'VAE',lora_name:'LoRA'};
|
||||
const field=e.extra_info?.input_name,value=e.extra_info?.received_value;
|
||||
if(e.type==='value_not_in_list' && labels[field] && typeof value==='string')return T("{label} not found: {value}",{label:labels[field],value});
|
||||
return `#${id} ${e.message || ''}: ${e.details || ''}`;
|
||||
}));
|
||||
const error = new Error(errors.join('\n') || data.error?.message || T("Workflow validation failed"));
|
||||
error.nodeErrors = data.node_errors || {}; throw error;
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw new Error(path === 'submit' ? T("Submission timed out. Check the task queue before retrying to avoid duplicate generations.") : T("Connection check timed out; check your network and retry"));
|
||||
throw error;
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {T} from './i18n.js';
|
||||
import { app } from '../../scripts/app.js';
|
||||
import { api } from '../../scripts/api.js';
|
||||
import {selectWorkbenchPipeline} from './workbench_analysis.js';
|
||||
import {executionId} from './workspace_flow.js';
|
||||
import { WorkspaceBindings } from './workspace_bindings.js';
|
||||
import { WorkspaceHistory } from './workspace_history.js';
|
||||
import { WorkspaceProgress } from './workspace_progress.js';
|
||||
import { installWorkspaceSplitter } from './workspace_splitter.js';
|
||||
import { prepareWorkspacePrompt, validateWorkspace, submitWorkspace } from './workspace_execution.js';
|
||||
import { syncCurrentDimensions } from './parameter_bridge.js?v=0.8.2';
|
||||
const el=(tag,text,cls)=>{const e=document.createElement(tag);if(text)e.textContent=text;if(cls)e.className=cls;return e;};
|
||||
const btn=(text,fn)=>{const b=el('button',text);b.type='button';b.onclick=fn;return b;};
|
||||
const icons={edit:'<path d="m14 3 7 7-11 11H3v-7L14 3Z"/><path d="m11 6 7 7"/>',compare:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 2v20M3 8h9M3 13h9M3 18h9"/>',side:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16M6 12h12m-3-3 3 3-3 3"/>',theme:'<path d="M20 15.5A9 9 0 0 1 8.5 4 9 9 0 1 0 20 15.5Z"/>',check:'<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',back:'<path d="m9 5-7 7 7 7M3 12h19"/>'};
|
||||
const iconButton=(label,kind,fn)=>{const b=btn('',fn);b.title=label;b.setAttribute('aria-label',label);b.innerHTML=`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[kind]}</svg>`;b.className='aw-icon';return b;};
|
||||
let currentWorkspace=null;
|
||||
export class Workspace {
|
||||
constructor(samplerNode=null,options={}){
|
||||
this.preferredSampler=samplerNode;this.pipelineOptions=options;this.mounts=[];this.disposers=[];this.closed=false;this.running=false;this.checkVersion=0;
|
||||
this.root=el('div','','adachi-workspace');this.root.adachiWorkspace=this;this.root.setAttribute('role','dialog');this.root.setAttribute('aria-label','Adachi Workbench');
|
||||
const head=el('header'),title=el('h1','Adachi Workbench');head.append(title);
|
||||
this.status=el('span',T("Reading the current workflow…"),'aw-status');this.themeButton=iconButton(T("Switch theme"),'theme',()=>this.toggleTheme());head.append(this.status,this.themeButton,iconButton(T("Check connections"),'check',()=>this.check()),iconButton(T("Return to workflow"),'back',()=>this.close()));this.root.append(head);
|
||||
const main=el('main'),canvas=el('section','','aw-canvas'),toolbar=el('div','','aw-toolbar');
|
||||
this.tabs={edit:iconButton(T("Edit mask"),'edit',()=>this.show('edit')),compare:iconButton(T("Slide to compare"),'compare',()=>this.show('compare'))};
|
||||
this.sideButton=iconButton(T("Show before"),'side',()=>this.show(this.view==='before'?'after':'before'));
|
||||
toolbar.append(this.tabs.edit,this.tabs.compare,this.sideButton);
|
||||
toolbar.append(el('span','','spacer'));this.adoptButton=btn(T("Adopt"),()=>this.adopt());this.adoptButton.title=T("Use this result as the next source image and keep the mask; restore earlier images below");this.adoptButton.className='primary';toolbar.append(this.adoptButton);
|
||||
this.surface=el('div','','aw-surface');this.still=el('img','','aw-result-image');this.still.alt=T("Selected source image or generated result");this.still.hidden=true;this.surface.append(this.still);
|
||||
const foot=el('div','','aw-footer');this.undoButton=btn(T("↶ Undo"),()=>this.restoreHistory('undo'));this.originalButton=btn(T("Initial source"),()=>this.restoreHistory('original'));this.historySelect=el('select');this.historySelect.setAttribute('aria-label',T("Choose an adopted source image"));this.historySelect.onchange=()=>this.restoreHistory(Number(this.historySelect.value));foot.append(this.undoButton,this.originalButton,this.historySelect);
|
||||
this.footer=foot;this.undoButton.title=T("Restore the previous source image, mask and crop");this.originalButton.title=T("Restore the initial source image of this session");this.historySelect.title=T("Choose an earlier adopted or uploaded source image");this.note=el('div','','aw-note');canvas.append(toolbar,this.surface,foot,this.note);
|
||||
this.side=el('aside');this.generationPanel=el('div','','aw-generation-progress');this.generationPanel.hidden=true;this.generationLabel=el('span');this.generationBar=el('progress');this.generationBar.max=1;this.generationBar.setAttribute('aria-label',T("Current stage progress"));this.generationPanel.title=T("Actual progress from the current node; stages without step counts show an indeterminate bar");this.generationPanel.append(this.generationLabel,this.generationBar);this.side.append(this.generationPanel,el('h2',T("Workflow settings")));this.settings=el('div');this.params=el('div','','aw-parameters');this.runButton=btn(T("Generate"),()=>this.run());this.runButton.className='primary';this.runButton.style.width='100%';this.side.append(this.settings,this.runButton,this.params);this.divider=el('div','','aw-splitter');main.append(canvas,this.divider,this.side);this.root.append(main);
|
||||
this.root.addEventListener('keydown',e=>{if(e.key==='Escape'&&e.target===this.root)this.close();});
|
||||
this.root.addEventListener('pointerdown',e=>e.stopPropagation());document.body.append(this.root);document.body.classList.add('adachi-workspace-open');this.stopSplitter=installWorkspaceSplitter(this.root,main,this.divider);
|
||||
// Render first. Discovery/catalog and actual server validation run afterward.
|
||||
let theme='light';try{theme=localStorage.getItem('adachi-workspace-theme')||theme;}catch{}this.setTheme(theme);
|
||||
setTimeout(()=>this.initialize().catch(e=>this.setStatus(e.message,'error')),0);
|
||||
}
|
||||
setTheme(theme){this.root.dataset.theme=theme==='dark'?'dark':'light';const label=this.root.dataset.theme==='dark'?T("Switch to light theme"):T("Switch to dark theme");this.themeButton.title=label;this.themeButton.setAttribute('aria-label',label);try{localStorage.setItem('adachi-workspace-theme',this.root.dataset.theme);}catch{}}
|
||||
toggleTheme(){this.setTheme(this.root.dataset.theme==='dark'?'light':'dark');}
|
||||
setStatus(text,state='pending'){if(this.closed)return;this.status.textContent=text;this.status.title=text;this.status.dataset.state=state;}
|
||||
renderTaskProgress(){
|
||||
const p=this.taskProgress;if(!p || this.closed)return;
|
||||
this.generationPanel.hidden=p.state==='idle';this.generationPanel.dataset.state=p.state;
|
||||
const known=p.max>0 && p.value!=null;
|
||||
this.generationLabel.textContent=p.label+(known && p.max>1?` · ${Math.round(p.value/p.max*100)}%`:'');
|
||||
this.generationBar.setAttribute('aria-valuetext',this.generationLabel.textContent);
|
||||
if(known)this.generationBar.value=p.value/p.max;else this.generationBar.removeAttribute('value');
|
||||
if(this.running && p.state!=='idle')this.setStatus(p.state==='complete'?T("Generation complete"):p.state==='error'?p.label:T("Generating"),p.state==='error'?'error':p.state==='complete'?'ok':'pending');
|
||||
}
|
||||
showGeneratedResult(){if(this.editor?.stroke || this.editor?.drag || this.editor?.pointerId!=null){this.pendingResultView=true;return;}this.pendingResultView=false;this.show('compare');}
|
||||
mount(element,target,canvas=false){
|
||||
if(!element?.parentNode)throw Error(T("Controls are not ready; reopen the workbench shortly"));
|
||||
const marker=document.createComment('workspace');element.before(marker);this.mounts.push({element,marker,style:element.getAttribute('style')});target.append(element);if(canvas)element.classList.add('aw-embedded');
|
||||
}
|
||||
async initialize(){
|
||||
if(this.closed)return;this.graph=app.rootGraph||app.graph;this.nodes=selectWorkbenchPipeline(this.graph,this.preferredSampler,this.pipelineOptions);
|
||||
this.editorNode=this.nodes.editor;this.sampler=this.nodes.sampler;this.compareNode=this.nodes.compare;this.connectionsConfirmed=!this.nodes.ambiguous||!!this.pipelineOptions.confirmed;
|
||||
this.editor=this.editorNode?.adachiEditor;this.compare=this.compareNode?.adachiCompare;
|
||||
if(!this.editor||!this.sampler?.adachiWorkbench||!this.compare)throw Error(T("This workflow needs Inline Mask, Adachi Inpaint Workbench and Image Compare nodes"));
|
||||
this.taskProgress=this.sampler.adachiWorkspaceProgress ||= new WorkspaceProgress();this.taskProgress.onChange=()=>this.renderTaskProgress();this.renderTaskProgress();
|
||||
this.history=this.editorNode.adachiWorkspaceHistory ||= new WorkspaceHistory(this.editorNode);
|
||||
this.history.onChange=()=>this.syncHistory();
|
||||
this.history.onRestore=()=>{syncCurrentDimensions(this.editorNode);app.canvas?.emitBeforeChange?.();app.canvas?.emitAfterChange?.();this.show('edit');this.scheduleCheck();};
|
||||
if(this.editor.ready)await this.history.checkpoint(T("Source when opened"));
|
||||
const originalTargets=this.compare.options.getTargets,originalManual=this.compare.manualTarget,originalTarget=this.compare.targetSelect.value;
|
||||
this.compare.options.getTargets=()=>({...originalTargets(),preferred:executionId(this.graph,this.editorNode)});this.compare.manualTarget=false;this.compare.refreshTargets();
|
||||
this.disposers.push(()=>{this.compare.options.getTargets=originalTargets;this.compare.manualTarget=originalManual;this.compare.targetSelect.value=originalTarget;this.compare.refreshTargets();});
|
||||
this.mount(this.editor.root,this.surface,true);this.mount(this.compare.root,this.surface,true);this.mount(this.sampler.adachiWorkbench.root,this.params);
|
||||
// The dock can wrap on narrow screens or with long history labels. Keep
|
||||
// crop/result controls above its measured height instead of a fixed offset.
|
||||
const dockObserver=new ResizeObserver(()=>{
|
||||
this.root.style.setProperty('--aw-history-height',`${this.footer.getBoundingClientRect().height}px`);
|
||||
const crop=this.editorNode.adachiCrop?.bar;
|
||||
if(crop)this.root.style.setProperty('--aw-crop-height',`${crop.getBoundingClientRect().height}px`);
|
||||
});
|
||||
dockObserver.observe(this.footer);if(this.editorNode.adachiCrop?.bar)dockObserver.observe(this.editorNode.adachiCrop.bar);
|
||||
this.disposers.push(()=>dockObserver.disconnect());
|
||||
const editorWheel=this.editor.options.onGraphWheel,compareWheel=this.compare.options.onGraphWheel;
|
||||
this.editor.options.onGraphWheel=()=>{};this.compare.options.onGraphWheel=()=>{};
|
||||
this.disposers.push(()=>{this.editor.options.onGraphWheel=editorWheel;this.compare.options.onGraphWheel=compareWheel;});
|
||||
this.bindings=new WorkspaceBindings({graph:this.graph,sampler:this.sampler,canvas:app.canvas,onChange:()=>this.scheduleCheck(),markChanged:()=>{app.canvas?.emitBeforeChange?.();app.canvas?.emitAfterChange?.();}});
|
||||
this.settings.append(this.connectionPicker(),this.bindings.createPanel());
|
||||
this.prepare(true).catch(e=>this.setStatus(e.message,'error'));
|
||||
this.bindings.loadCatalog(api.fetchApi.bind(api)).then(()=>this.scheduleCheck()).catch(e=>this.setStatus(e.message,'error'));
|
||||
const oldAdopt=this.compare.options.onAdopt;
|
||||
this.compare.options.onAdopt=async(...args)=>{const result=await this.history.transact(T("Adopted result"),()=>oldAdopt(...args));if(!this.closed && this.editorNode.adachiCrop?.mode==='crop')this.show('edit');return result;};
|
||||
this.disposers.push(()=>{this.compare.options.onAdopt=oldAdopt;});
|
||||
const oldLoad=this.compare.loadSelected;
|
||||
this.compare.loadSelected=async(...args)=>{const result=await oldLoad.apply(this.compare,args);if(!this.closed){this.show(this.view||'edit');this.updateButtons();}return result;};
|
||||
this.disposers.push(()=>{this.compare.loadSelected=oldLoad;});
|
||||
const oldUpload=this.editor.upload;
|
||||
this.editor.upload=async(...args)=>{await oldUpload(...args);if(this.editor.ready&&!this.closed){await this.history.checkpoint(T("Uploaded source"));this.syncHistory();this.scheduleCheck();}};
|
||||
this.disposers.push(()=>{this.editor.upload=oldUpload;});
|
||||
const onExecuted=this.compareNode.onExecuted;
|
||||
this.compareNode.onExecuted=(output)=>{const ret=onExecuted?.call(this.compareNode,output);if(output?.adachi_results?.length){this.showGeneratedResult();this.note.textContent='';this.updateButtons();}return ret;};
|
||||
this.disposers.push(()=>{this.compareNode.onExecuted=onExecuted;});
|
||||
const event=(name,fn)=>{api.addEventListener(name,fn);this.disposers.push(()=>api.removeEventListener(name,fn));};
|
||||
for(const name of ['execution_start','executing','execution_cached','progress','progress_state','execution_success','execution_error','execution_interrupted'])event(name,e=>this.taskProgress.handle(name,e.detail));
|
||||
event('reconnecting',()=>this.taskProgress.connection(false));event('reconnected',()=>this.taskProgress.connection(true));
|
||||
const recovered=e=>{if(e.detail.node!==this.compareNode || e.detail.promptId!==this.promptId)return;this.taskProgress.finish(e.detail.entry.status?.status_str==='success');this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent=T("Generate");this.setStatus(e.detail.entry.status?.status_str==='success'?T("Generation complete"):T("Generation failed; check the ComfyUI task history"),e.detail.entry.status?.status_str==='success'?'ok':'error');this.updateButtons();};
|
||||
document.addEventListener('adachi-result-recovered',recovered);this.disposers.push(()=>document.removeEventListener('adachi-result-recovered',recovered));
|
||||
for(const name of ['execution_success','execution_error','execution_interrupted'])event(name,e=>{if(e.detail.prompt_id===this.promptId){this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent=T("Generate");this.setStatus(name==='execution_success'?T("Generation complete"):e.detail.exception_message||T("Generation stopped"),name==='execution_success'?'ok':'error');}});
|
||||
this.timer=setInterval(()=>{if((app.rootGraph||app.graph)!==this.graph||this.editor.destroyed)this.close();else{this.updateButtons();if(this.pendingResultView)this.showGeneratedResult();}},500);
|
||||
this.pollTimer=setInterval(()=>this.pollResult(),2500);
|
||||
this.promptId=this.sampler.adachiWorkspaceTask;this.running=!!this.promptId;if(this.running){if(this.taskProgress.state==='idle')this.taskProgress.begin({},this.promptId);this.compareNode.adachiTrackPrompt?.(this.promptId);this.runButton.disabled=true;this.runButton.textContent=T("Generating…");}this.show('edit');this.syncHistory();this.scheduleCheck();
|
||||
}
|
||||
connectionPicker(){
|
||||
const panel=el('details','','aw-connections');panel.open=!this.connectionsConfirmed;panel.append(el('summary',T("Workflow connections")));
|
||||
if(!this.connectionsConfirmed)panel.append(el('p',T("Multiple candidates found; select the nodes to use")));
|
||||
const controls={};
|
||||
for(const [name,label,list,current]of [['sampler',T("Inpaint node"),this.nodes.samplers,this.sampler],['editor',T("Image editor"),this.nodes.editors,this.editorNode],['compare',T("Image comparison"),this.nodes.comparisons,this.compareNode]]){
|
||||
const row=el('label',label),select=el('select');for(const node of list){const id=executionId(this.graph,node),option=el('option',`${node.title||node.type} #${id}`);option.value=id;select.append(option);}select.value=executionId(this.graph,current);row.append(select);panel.append(row);controls[name]=select;
|
||||
}
|
||||
const presetRow=el('label',T("Group presets")),preset=el('select'),none=el('option',T("No preset controller"));none.value='';preset.append(none);
|
||||
for(const node of this.nodes.presets.filter(n=>n.mode===0)){const id=executionId(this.graph,node),option=el('option',`${node.title||node.type} #${id}`);option.value=id;preset.append(option);}preset.value=this.bindings.controller?executionId(this.graph,this.bindings.controller.node):'';presetRow.append(preset);panel.append(presetRow);
|
||||
const apply=btn(T("Use these connections"),()=>{
|
||||
const find=(list,value)=>list.find(node=>executionId(this.graph,node)===value),sampler=find(this.nodes.samplers,controls.sampler.value),editor=find(this.nodes.editors,controls.editor.value),compare=find(this.nodes.comparisons,controls.compare.value);
|
||||
if(!sampler||!editor||!compare)return;sampler.properties.adachi_workspace_connections={editorId:executionId(this.graph,editor),compareId:executionId(this.graph,compare),controllerId:preset.value};
|
||||
this.close();currentWorkspace=new Workspace(sampler,{editorNode:editor,compareNode:compare,confirmed:true});
|
||||
});apply.title=T("Select nodes from this workflow; this inpaint node remembers your choices");panel.append(apply);return panel;
|
||||
}
|
||||
syncHistory(){if(!this.history)return;this.historySelect.replaceChildren();this.history.entries.forEach((item,i)=>{const o=el('option',`${i+1} · ${item.label}`);o.value=i;this.historySelect.append(o);});this.historySelect.value=String(this.history.index);this.undoButton.disabled=!this.history.canUndo;this.originalButton.disabled=this.history.index===0;}
|
||||
updateButtons(){const busy=this.running||this.preparing;this.adoptButton.disabled=!this.compare?.ready||!!this.compare?.busy||this.compare?.pendingOutputs>0||busy;this.tabs.compare.disabled=!this.compare?.ready;this.sideButton.disabled=!this.compare?.ready;if(!this.preparing)this.bindings?.refreshProfiles();if(this.editor)this.editor.root.inert=(!this.connectionsConfirmed)||!!this.preparing&&!this.discovering;this.settings.inert=!!this.preparing;this.params.inert=!!this.preparing;this.historySelect.disabled=this.preparing||!!this.history?.busy;this.undoButton.disabled=this.preparing||!this.history?.canUndo;this.originalButton.disabled=this.preparing||this.history?.index<=0;if(this.compare?.adoptButton)this.compare.adoptButton.disabled=this.adoptButton.disabled;}
|
||||
show(which){this.view=which;if(this.editor)this.editor.root.hidden=which!=='edit';if(this.compare)this.compare.root.hidden=which!=='compare';this.still.hidden=!['before','after'].includes(which);if(!this.still.hidden){const image=which==='before'?this.compare?.before:this.compare?.after;if(image)this.still.src=image.src;}for(const [id,b]of Object.entries(this.tabs)){b.classList.toggle('active',id===which);b.setAttribute('aria-pressed',String(id===which));}this.sideButton.classList.toggle('active',['before','after'].includes(which));this.sideButton.setAttribute('aria-pressed',String(which==='before'));this.sideButton.title=which==='before'?T("Show after"):T("Show before");this.sideButton.setAttribute('aria-label',this.sideButton.title);this.editor?.schedule();this.compare?.schedule();}
|
||||
async adopt(){if(!this.compare?.ready||this.running)return;await this.compare.adopt();this.syncHistory();this.updateButtons();if(!this.compare.message.classList.contains('dd-error')){this.show(this.editorNode.adachiCrop?.mode==='crop'?'edit':'compare');this.note.textContent='';this.scheduleCheck();}}
|
||||
async restoreHistory(which){try{if(this.preparing||this.history?.busy)return;if(which==='undo')await this.history.undo();else if(which==='original')await this.history.original();else await this.history.restore(which);this.syncHistory();}catch(e){this.setStatus(e.message,'error');}}
|
||||
scheduleCheck(){clearTimeout(this.checkTimer);this.checkTimer=setTimeout(()=>this.check(),200);}
|
||||
prepare(discovery=false){
|
||||
const task=(this.prepareTask||Promise.resolve()).catch(()=>{}).then(async()=>{
|
||||
if(this.closed||(app.rootGraph||app.graph)!==this.graph)throw Error(T("The workflow changed"));
|
||||
if(!this.connectionsConfirmed&&!discovery)throw Error(T("Select the nodes to use in Workflow connections first"));
|
||||
if(!this.editor?.ready&&!discovery)throw Error(T("Upload a source image first"));
|
||||
this.preparing=true;this.discovering=discovery;this.updateButtons();
|
||||
try{return await prepareWorkspacePrompt({editorNode:this.editorNode,samplerNode:this.sampler,compareNode:this.compareNode,allowEmpty:discovery,controller:this.bindings.controller,getBinding:(output,id)=>{this.bindings.updateExecutionGraph(output,id);return this.bindings.getExecutionBinding();}});}
|
||||
finally{this.preparing=false;this.discovering=false;if(!this.closed)this.updateButtons();}
|
||||
});
|
||||
this.prepareTask=task;return task;
|
||||
}
|
||||
async check(){const version=++this.checkVersion;if(this.closed||this.running)return false;this.setStatus(T("Checking in the background…"));try{const prepared=await this.prepare();const result=await validateWorkspace(prepared);if(this.closed||this.running||version!==this.checkVersion)return false;if(result.valid===false||result.ok===false)throw Error(result.error?.message||result.error||T("Workflow validation failed"));this.setStatus(T("Ready to generate"),'ok');return true;}catch(e){if(version===this.checkVersion && !this.running)this.setStatus(e.message||String(e),'error');return false;}}
|
||||
async run() {
|
||||
if(this.running)return;
|
||||
++this.checkVersion;clearTimeout(this.checkTimer);
|
||||
this.runButton.disabled=true; this.runButton.textContent=T("Checking…");
|
||||
const seed=this.sampler?.widgets?.find(w=>w.name==='seed'), oldSeed=seed?.value;
|
||||
let queuedSeed=oldSeed;
|
||||
try {
|
||||
for(const widget of this.sampler.widgets || []) widget.beforeQueued?.();
|
||||
queuedSeed=seed?.value;
|
||||
const prepared=structuredClone(await this.prepare());
|
||||
await validateWorkspace(prepared);
|
||||
if(this.closed)return;
|
||||
this.running=true;this.taskProgress.begin(prepared.prompt);this.promptId=null; this.updateButtons();
|
||||
const queued=await submitWorkspace(prepared);
|
||||
this.promptId=queued.prompt_id;this.taskProgress.attach(this.promptId);
|
||||
this.compareNode.adachiTrackPrompt?.(this.promptId);
|
||||
for(const widget of this.sampler.widgets || []) widget.afterQueued?.();
|
||||
this.sampler.adachiParameters.refresh();
|
||||
this.running=!['complete','error'].includes(this.taskProgress.state);
|
||||
this.sampler.adachiWorkspaceTask=this.running?this.promptId:null;
|
||||
this.runButton.textContent=this.running?T("Generating…"):T("Generate");
|
||||
this.setStatus(this.running?T("Queued for generation"):this.taskProgress.label,this.running?'pending':this.taskProgress.state==='error'?'error':'ok');
|
||||
} catch(e) {
|
||||
this.running=false;
|
||||
if(['queued','running'].includes(this.taskProgress.state))this.taskProgress.finish(false,e.message||String(e));
|
||||
if(seed && seed.value===queuedSeed)seed.value=oldSeed;
|
||||
this.setStatus(e.message||String(e),'error');
|
||||
} finally {
|
||||
if(!this.running){this.runButton.disabled=false;this.runButton.textContent=T("Generate");}
|
||||
this.updateButtons();
|
||||
}
|
||||
}
|
||||
|
||||
async pollResult(){if(!this.running||!this.promptId||this.polling||this.closed)return;this.polling=true;try{const r=await api.fetchApi(`/history/${encodeURIComponent(this.promptId)}`);if(!r.ok)return;const all=await r.json(),entry=all[this.promptId];if(!entry?.status?.completed)return;const output=entry.outputs?.[executionId(this.graph,this.compareNode)];if(output)this.compareNode.onExecuted(output);this.taskProgress.finish(entry.status.status_str==='success');this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent=T("Generate");this.setStatus(entry.status.status_str==='success'?T("Generation complete"):T("Generation failed; check the ComfyUI error log"),entry.status.status_str==='success'?'ok':'error');}catch{}finally{this.polling=false;}}
|
||||
close(){if(this.closed)return;this.closed=true;++this.checkVersion;clearInterval(this.timer);clearInterval(this.pollTimer);clearTimeout(this.checkTimer);this.stopSplitter?.();for(const dispose of this.disposers.reverse())dispose();if(this.history){this.history.onChange=null;this.history.onRestore=null;}if(this.taskProgress)this.taskProgress.onChange=()=>{};this.bindings?.destroy();for(const {element,marker,style}of this.mounts.reverse()){element.classList.remove('aw-embedded');element.hidden=false;element.inert=false;if(marker.parentNode)marker.replaceWith(element);if(style===null)element.removeAttribute('style');else element.setAttribute('style',style);}this.root.remove();document.body.classList.remove('adachi-workspace-open');this.editor?.schedule();this.compare?.schedule();this.compare?.updateButton();currentWorkspace=null;}
|
||||
}
|
||||
app.registerExtension({name:'Adachi.InpaintWorkspace',setup(){
|
||||
const css=document.createElement('link');css.rel='stylesheet';css.href=new URL('./workspace.css?v=2.0.0',import.meta.url).href;document.head.append(css);
|
||||
document.addEventListener('adachi-open-workspace',event=>{if(!currentWorkspace)currentWorkspace=new Workspace(event.detail?.sampler||null);});
|
||||
}});
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Validate and queue only the selected inpainting output, without a test render."""
|
||||
|
||||
from copy import deepcopy
|
||||
import inspect
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
def dependency_prompt(prompt, output_id):
|
||||
"""Keep one output and its dependencies, including references into subgraphs."""
|
||||
output_id = str(output_id)
|
||||
if not isinstance(prompt, dict) or not isinstance(prompt.get(output_id), dict):
|
||||
raise ValueError("The selected comparison is not present in the execution graph.")
|
||||
if prompt[output_id].get("class_type") != "AdachiImageCompare":
|
||||
raise ValueError("The workspace output must be an Adachi Image Compare node.")
|
||||
found, pending = set(), [output_id]
|
||||
while pending:
|
||||
key = pending.pop()
|
||||
if key in found:
|
||||
continue
|
||||
found.add(key)
|
||||
node = prompt.get(key)
|
||||
if not isinstance(node, dict):
|
||||
raise ValueError(f"The execution graph references a missing node: {key}")
|
||||
for value in node.get("inputs", {}).values():
|
||||
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str) and isinstance(value[1], int):
|
||||
pending.append(value[0])
|
||||
return {key: value for key, value in prompt.items() if key in found}
|
||||
|
||||
|
||||
async def prepare_workspace(server, execution, payload):
|
||||
"""Use the same hooks, replacements and validation as ComfyUI /prompt."""
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("prompt"), dict):
|
||||
raise ValueError("No workflow execution graph was provided.")
|
||||
target = str(payload.get("compare_id", ""))
|
||||
data = deepcopy(payload)
|
||||
# Hooks may restore Get/Set or subgraph links; prune only after they ran.
|
||||
data = server.trigger_on_prompt(data)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("prompt"), dict):
|
||||
raise ValueError("A workflow extension rejected the execution graph.")
|
||||
replacements = getattr(server, "node_replace_manager", None)
|
||||
if replacements is not None:
|
||||
replacements.apply_replacements(data["prompt"])
|
||||
data["prompt"] = dependency_prompt(data["prompt"], target)
|
||||
data["partial_execution_targets"] = [target]
|
||||
prompt_id = str(uuid.uuid4())
|
||||
# Older supported ComfyUI builds have validate_prompt(prompt), newer builds
|
||||
# include prompt_id and optional partial execution targets.
|
||||
count = len(inspect.signature(execution.validate_prompt).parameters)
|
||||
if count >= 3:
|
||||
checked = execution.validate_prompt(prompt_id, data["prompt"], [target])
|
||||
elif count == 2:
|
||||
checked = execution.validate_prompt(prompt_id, data["prompt"])
|
||||
else:
|
||||
checked = execution.validate_prompt(data["prompt"])
|
||||
valid = await checked if inspect.isawaitable(checked) else checked
|
||||
return data, prompt_id, valid
|
||||
|
||||
|
||||
def register_workspace_routes(server):
|
||||
if getattr(server, "_adachi_workspace_execution_routes", False):
|
||||
return
|
||||
from aiohttp import web
|
||||
import execution
|
||||
|
||||
@server.routes.get("/adachi-inpaint/workspace/capabilities")
|
||||
async def capabilities(request):
|
||||
return web.json_response({"api_version": 1, "can_validate": True})
|
||||
|
||||
async def handle(request, submit):
|
||||
try:
|
||||
payload = await request.json()
|
||||
data, prompt_id, valid = await prepare_workspace(server, execution, payload)
|
||||
response = {"valid": bool(valid[0]), "error": valid[1], "node_errors": valid[3]}
|
||||
if not valid[0]:
|
||||
return web.json_response(response, status=400 if submit else 200)
|
||||
if submit:
|
||||
extra = data.get("extra_data", {})
|
||||
if not isinstance(extra, dict):
|
||||
raise ValueError("Invalid workflow metadata.")
|
||||
if isinstance(data.get("client_id"), str):
|
||||
extra["client_id"] = data["client_id"]
|
||||
extra["create_time"] = int(time.time() * 1000)
|
||||
sensitive = {}
|
||||
sensitive_keys = getattr(execution, "SENSITIVE_EXTRA_DATA_KEYS", None)
|
||||
for key in sensitive_keys or ():
|
||||
if key in extra:
|
||||
sensitive[key] = extra.pop(key)
|
||||
number = server.number
|
||||
server.number += 1
|
||||
item = (number, prompt_id, data["prompt"], extra, valid[2])
|
||||
if sensitive_keys is not None:
|
||||
item += (sensitive,)
|
||||
server.prompt_queue.put(item)
|
||||
response.update(prompt_id=prompt_id, number=number)
|
||||
else:
|
||||
response["outputs"] = valid[2]
|
||||
response["node_count"] = len(data["prompt"])
|
||||
return web.json_response(response)
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
return web.json_response({"valid": False, "error": {"message": str(exc)}, "node_errors": {}}, status=400)
|
||||
|
||||
@server.routes.post("/adachi-inpaint/workspace/validate")
|
||||
async def validate(request):
|
||||
return await handle(request, False)
|
||||
|
||||
@server.routes.post("/adachi-inpaint/workspace/submit")
|
||||
async def submit(request):
|
||||
return await handle(request, True)
|
||||
|
||||
server._adachi_workspace_execution_routes = True
|
||||
Reference in New Issue
Block a user