Compare commits

..
5 Commits
15 changed files with 394 additions and 54 deletions
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## 0.5.12
- Trace enabled Get nodes back through their named Set and encoder in saved workflow metadata; ignored Sets disable their branch even when both Gets remain enabled and both execution links survive.
- Normalize exclusive branch flags and remove only ignored execution inputs before ComfyUI validation/cache lookup. Keep all original workflow wires and normal cache reuse.
- Repeat the workflow check during lazy selection, support scoped subgraph IDs and both serialized link formats, and retain raw-API behavior without workflow metadata.
- Validate the supplied remote workflow's routing states using lightweight real image queues, including reverse switching, cache reuse and strict invalid states. A full ComfyUI restart is required after updating.
## 0.5.11
- Resolve exclusive selection against the actual submitted prompt connections, overriding stale frontend flags when only one branch survives Get/Set, mute or bypass resolution.
- Support dynamically expanded subgraph node IDs through ComfyUI's dynamic prompt, while retaining explicit branch flags for bypassed transforms that keep both physical connections.
- Keep both-enabled/both-absent errors and include the received branch state in their diagnostic message. Restart ComfyUI after updating this backend change.
## 0.5.10
- Add a pin toggle beside Fit in Inline Mask: keep the image fitted, route plain wheel to the workflow, retain Ctrl+wheel brush sizing and save the toggle with the workflow.
- Shorten the inline load button to Upload (上传).
- Bound inline preview bitmap allocation to source detail and an 8 Mi-pixel budget; cache the source/mask composite during cursor movement. Full-resolution mask data remains unchanged.
- Successful adoption resets comparison history to the accepted image as 1/1. The next generation starts a fresh result list; failed adoption preserves history.
## 0.5.9
- Bound comparison bitmap resolution to source detail, 8192 pixels per edge and a 16 Mi-pixel allocation budget, avoiding quadratic bitmap growth at large workflow zoom.
- Redraw only the strip swept by the comparison divider during pointer movement; redraw fully after image, size or hover changes.
- Draw the divider as a separate overlay so it remains one screen pixel wide even when magnifying the workflow.
## 0.5.8
- Count each comparison preview file once, even when cached execution notifications replay it; keep the selected result on replay.
- Remove duplicate preview entries when reloading older saved comparison histories.
## 0.5.7
- Accumulate comparison results across runs, select the newest result and preserve each result's matching original, including after workflow reload.
+8 -4
View File
@@ -86,7 +86,7 @@ When the mask touches an image edge, masked-area mode uses aspect-preserving fil
| Image / mask / overlay | Preview toggles only; they do not change the output |
Brush pressure and soft brushes are supported; the mouse uses a fixed pressure.
Inline Mask keeps only the image editing area and small **Load image** / **Fit** buttons in the corner, and adjusts the canvas height to the image ratio after loading to reduce empty margins.
Inline Mask keeps the image editing area and small **Upload** / **Fit** / pin buttons in the corner, and adjusts the canvas height to the image ratio after loading to reduce empty margins.
The brush, eraser, rectangle, lasso, pan, four brush sliders, undo/redo, mask actions, view toggles, fit and the embed option all live in the connected **WebUI Inpaint** node and drive that same inline canvas.
You upload the image once; every stroke then updates the mask in the browser and is stored as PNG data in the workflow and in the executed parameters, so there is no saving and reloading of files per stroke.
Wheel events over the picture zoom it; over the toolbar, buttons, sliders or the parameter panel they follow ComfyUI's canvas navigation settings.
@@ -95,6 +95,8 @@ Very large images still use browser memory, and PNG encoding time depends on res
## Painting tools and inpainting parameters
The Inline Mask header has **Upload**, **Fit** and a pin toggle (**Keep** / **保持**). Enable the pin to keep the picture fitted and centered: plain wheel navigates the workflow, Ctrl+wheel still adjusts the brush, and image panning is disabled. Disable the pin to resume image zoom and pan. The pin state is saved with the workflow. Display buffers are bounded during workflow zoom; the saved mask retains its original resolution.
Once Inline Mask is connected to the `image` input of **WebUI Inpaint**, the original painting controls appear in that inpaint node, and the parameter panel below drives the node's real sampling parameters.
Width and height each get their own row with both a slider and a number field; denoise is a slider too, and **Use source size** fills in the source dimensions, rounded to the nearest multiple of 8 the sampler requires.
Mask mode, fill, sampling area, sampler, scheduler, steps, CFG and seed are shown directly; mask processing, batch and Refiner switching live under **Mask, batch and advanced settings**.
@@ -105,11 +107,11 @@ Parameters driven by an external link disable the matching panel control; a modu
Add an **Image Compare** node and connect Inline Mask's `image` to `original` and the final full-size generated `image` to `result`, then run.
With the pointer over the picture, the thin divider shows the generated result on the left and the original on the right; leaving the picture hides the divider and shows the full result, and the image always remains fitted and centered in its canvas. Scrolling over the comparison image navigates the ComfyUI workflow background using the current navigation settings; comparison images do not zoom or pan independently. Batch selection, dimensions and the fit button sit below the picture.
When you are happy, click **Adopt this result**: the plugin uploads the selected result as the new source image and writes it back into the upstream inline editor while preserving the painted mask, current undo/redo history and viewport. Use **Clear** when you want to start a new mask; a normal **Load image** upload still starts a fresh mask.
When you are happy, click **Adopt this result**: the plugin uploads the selected result as the new source image and writes it back into the upstream inline editor while preserving the painted mask, current undo/redo history and viewport. Use **Clear** when you want to start a new mask; a normal **Upload** still starts a fresh mask.
To preserve mask coordinates, an adopted result must match the source dimensions; a mismatched result is rejected without changing the current source or mask. The mask remains embedded in saved workflow data; command-level undo history is retained during adoption in the current session.
If the original is routed through intermediate IMAGE nodes the plugin walks upstream to find the inline editor; when several candidates exist you can pick one explicitly under **Adopt into**.
Adoption only happens when you click the button and never starts the next generation automatically; the `image` output still passes the whole result batch, so you can feed a Save Image node.
The result list accumulates images across runs and selects the newest result automatically. Each result keeps its original from that run, including when adopting results for further inpainting. Saving the workflow also saves this history's preview references and selection.
The result list accumulates images across runs and selects the newest result automatically. Each result keeps its original from that run. Successful adoption clears the old list and displays the accepted image as 1/1; the next generation starts a fresh list using the new source. Failed adoption leaves the old list intact. Saving the workflow also saves preview references, selection and the pending new-round state.
Comparison previews live in ComfyUI's temp directory and may need a re-run after cleanup or restart; an adopted source image is stored in `input/adachi-inpaint/` and does not depend on temp previews.
@@ -159,6 +161,8 @@ Place the selector outside the two controlled groups and connect each group's fi
KJNodes Get/Set routes and reroutes are supported: an ignored/muted Get, its corresponding Set, or the source behind those routing nodes disables that selector branch. Missing setters and virtual routing cycles also count as unavailable. Ordinary processing nodes remain independent active sources; the selector does not guess the meaning of arbitrary processing chains.
The ignored branch is not requested by this selector; any separate preview/save outputs on that branch may still execute independently.
For raw API prompts, omit the ignored input and omit the internal `active_inputs` field; exactly one of `input_a` / `input_b` must remain.
Without workflow metadata, the backend checks actual submitted connections after virtual routing: when only one input survives, it uses that branch even if cached frontend flags still report both enabled. With both connections present and no workflow metadata, the frontend flags remain necessary to distinguish bypassed transform pass-through from an enabled branch. Updating this backend logic requires a full ComfyUI restart as well as a browser refresh.
When workflow metadata accompanies the request, the server independently follows Get → named Set → source node. Both Gets can stay enabled: bypassing one branch's Set or encoder disables that branch. The server normalizes flags and removes ignored execution inputs before validation and cache lookup, while the saved workflow retains its connections. Without workflow metadata the submitted connections/frontend flags remain the fallback.
## Using a standard sampling workflow
@@ -212,7 +216,7 @@ python -m unittest discover -s tests -v
想用中文界面时,把 ComfyUI 的语言切成中文即可,插件会自动跟随:
在 ComfyUI 设置里找到 **Locale / 语言**(设置项 `Comfy.Locale`)选择 `zh`,然后刷新浏览器(`Ctrl+F5`)。
节点名称、分类、输入输出名与提示由 `locales/zh/` 提供,通过 ComfyUI 的 `/i18n` 机制加载;
画布按钮与参数面板由 `web/i18n.js` 读取同一个语言设置翻译,因此「宽度」「高度」「载入原图」等也会一起变回中文。
画布按钮与参数面板由 `web/i18n.js` 读取同一个语言设置翻译,因此「宽度」「高度」「上传」等也会一起变回中文。
英文用户看到的一直是英文(英文是内置默认值);某个词条若没有对应翻译,会自动回退为英文。
两套语言共用同一个节点类 ID 和参数键名,所以已保存的工作流不受语言切换影响。
+9 -4
View File
@@ -1,10 +1,15 @@
# Git versions and rollback
The current local version is **v0.5.7**. Both the installed plugin and the source repository retain local commits and tags. This update is also pushed to the GitHub main branch.
The current local version is **v0.5.12**. Both the installed plugin and the source repository retain local commits and tags. This update is also pushed to the GitHub main branch.
| Tag | Contents |
|---|---|
| `v0.5.7` | Fixed comparison images; wheel navigates the workflow; retain result history across runs |
| `v0.5.12` | Trace Get/Set source states server-side before prompt validation and caching |
| `v0.5.11` / `local-before-v0.5.12` | Resolve exclusive selection from actual submitted connections despite stale frontend flags |
| `v0.5.10` / `local-before-v0.5.11` | Pin inline fit, bound/cache inline previews, Upload label and restart comparison rounds after adoption |
| `v0.5.9` / `local-before-v0.5.10` | Bound comparison bitmap allocations and redraw only the swept divider strip |
| `v0.5.8` / `local-before-v0.5.9` | Deduplicate replayed comparison previews and repair duplicate saved history |
| `v0.5.7` / `local-before-v0.5.8` | Fixed comparison images; wheel navigates the workflow; retain result history across runs |
| `v0.5.6` / `local-before-v0.5.7` | Exclusive selector follows Get/Set and virtual routes correctly |
| `v0.5.5` / `local-before-v0.5.6` | Bind canvas/panel widths to node geometry and retain vertical resizing |
| `v0.5.4` / `local-before-v0.5.5` | Isolate Fit clicks and refresh editor assets on upgrade |
@@ -19,13 +24,13 @@ To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach local-before-v0.5.7
git switch --detach local-before-v0.5.12
```
Return to this version with:
```powershell
git switch --detach v0.5.7
git switch --detach v0.5.12
```
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
+8
View File
@@ -1,5 +1,9 @@
# Verification record
- 0.5.12: all 37 CPU unit tests pass. The supplied remote workflow was used as request metadata with lightweight red/blue image probes replacing model generation: positive selector 532 chooses B, negative 533 chooses A, all four Gets remain enabled. Ten real queue cases cover reverse Set/source states, switching back, encoder-only and Set-only bypass, unchanged-workflow preservation, cache reuse, and both-enabled/both-ignored errors. No complete Anima model generation is claimed by these routing probes. Sixteen native KJ Get/Set browser queue cases pass on frontend 1.52.7 in both renderers, and all 15 metadata-free stale-state API queue cases still pass.
- 0.5.11: all 32 backend CPU tests pass, including stale branch flags with either single submitted input, strict empty/ambiguous input errors, both-connected passthrough selection and dynamic subgraph node lookup. Fifteen real CPU queue cases verify single-branch A/B output pixels for flags -1 through 3, both-connected A/B selection, and invalid cases. Sixteen real KJ Get/Set queue cases on frontend 1.52.7 cover both renderers, source/Set/Get ignore and mute states, reverse switching, both-invalid states and workflow reload.
Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.8.0, Pillow 11.0.0, Chrome 155, using a separate port 8190 CPU service with its own input/output directories and an in-memory database.
- 16 backend CPU tests pass, covering both sampling areas, edge masks, inversion, growth, feathering, surrounding-colour fill, batch mapping, source-pixel protection, empty masks, `denoise=0`, latent noise/zero modes, dedicated inpaint conditioning, different VAE compression ratios and Refiner staging.
@@ -49,6 +53,10 @@ Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.
- 0.5.7: actual browser wheel events over a real queued comparison image change workflow navigation while the comparison transform stays fitted and centered. Verified on frontends 1.42.11 and 1.52.7 in classic nodes and Nodes 2.0, with left-result/right-original pixels, hover/leave behavior, batch selection, adoption preserving mask/history, workflow reload, rejected mismatched adoption and ordinary-upload mask reset.
- 0.5.7: three real queued two-image batches accumulate 2, 4 and 6 results and select the newest result. After adoption, old results retain their old original and new results use the adopted original; all six results survive workflow reload in both frontend versions/renderers. Dependency-free tests additionally cover legacy batch mapping, empty outputs and immutable appending.
- 0.5.8: real single-image queues on frontend 1.52.7 in classic nodes and Nodes 2.0 show 1/1 then 2/2. Replaying the same execution output twice does not increase the count or reset a manually selected result. Reloading a workflow containing duplicate preview references removes duplicates. Unit checks distinguish filenames, subfolders and file types.
- 0.5.9: on frontend 1.52.7 in classic nodes and Nodes 2.0, a 1024x1536 comparison was measured at workflow zoom 1x, 2x and 4x. At 4x, 24 synchronous paints with readback dropped from 483 to 36 ms (classic) and 453 to 35 ms (Nodes 2.0). These are isolated browser measurements, not end-to-end workflow FPS guarantees. The divider measured one screen pixel at every zoom. Dirty-strip redraws matched full-redraw pixels exactly across forward/reverse/unchanged divider movements and edge positions; pointer exit hides the divider.
- 0.5.10: frontends 1.42.11 and 1.52.7, both classic nodes and Nodes 2.0, verify the Upload label and pin icon beside Fit, pin persistence across workflow reload, plain wheel navigating the workflow, Ctrl+wheel brush sizing while pinned, and restored image wheel zoom after unpinning. At 4x graph zoom the display allocation remains bounded, the original mask dimensions remain 320x200, and 20 cursor-only redraws never recomposite the mask overlay. All 15 standalone editor regression groups pass, including real painting, Alt/right-button erase, undo/redo, shapes, pressure, serialization and zoom/pan coordinate mapping.
- 0.5.10: actual adoption reduces the comparison list to 1/1, preserves mask PNG and undo/redo identities, and saves the pending new-round state. Reloading before generating retains that state; the next two-image batches start at 2/2 and then accumulate to 4/4. Rejected adoption leaves history unchanged. These checks pass in both frontend versions/renderers.
The sampling path was also checked for noise-schedule continuity against the real `common_ksampler`/`KSampler` functions in the local ComfyUI; an actual checkpoint has not been loaded to test GPU image quality or every model family, so CPU/interface tests must not be treated as model-quality verification.
+6 -1
View File
@@ -1,6 +1,11 @@
"""Adachi-Inpaint-Workbench custom-node package for ComfyUI."""
from .backend import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
from .backend import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, normalize_exclusive_prompt
from server import PromptServer
if not getattr(PromptServer.instance, "_adachi_exclusive_normalizer", False):
PromptServer.instance.add_on_prompt_handler(normalize_exclusive_prompt)
PromptServer.instance._adachi_exclusive_normalizer = True
WEB_DIRECTORY = "./web"
+109 -6
View File
@@ -618,6 +618,88 @@ class AdachiImageCompare:
_MISSING_INPUT = object()
def _workflow_exclusive_flags(extra_pnginfo, unique_id):
"""Recover routing states from the editable graph, before virtual links vanish."""
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
if not isinstance(workflow, dict) or unique_id is None:
return None
definitions = {str(g.get("id")): g for g in workflow.get("definitions", {}).get("subgraphs", [])}
graph = workflow
parts = str(unique_id).split(":")
for parent_id in parts[:-1]:
parent = next((n for n in graph.get("nodes", []) if str(n.get("id")) == parent_id), None)
graph = definitions.get(str(parent.get("type"))) if parent else None
if graph is None:
return None
nodes = {str(n.get("id")): n for n in graph.get("nodes", [])}
selector = nodes.get(parts[-1])
if not selector or selector.get("type") != "AdachiExclusiveSwitch":
return None
links = {}
for link in graph.get("links", []):
if isinstance(link, dict):
links[str(link["id"])] = (str(link["origin_id"]), link.get("origin_slot", 0))
else:
links[str(link[0])] = (str(link[1]), link[2])
def visit(node_id, slot, seen):
if node_id in seen:
return False
node = nodes.get(node_id)
if node is None:
return None # e.g. a subgraph's external input placeholder
if node.get("mode", 0) in (2, 4):
return False
seen = seen | {node_id}
kind = node.get("type")
if kind == "GetNode":
values = node.get("widgets_values", [])
name = values[0] if isinstance(values, list) and values else None
setter = next((n for n in nodes.values() if n.get("type") == "SetNode"
and isinstance(n.get("widgets_values"), list)
and n["widgets_values"] and n["widgets_values"][0] == name), None) if name else None
return visit(str(setter["id"]), slot, seen) if setter else False
if kind in ("SetNode", "Reroute"):
inputs = node.get("inputs", [])
if not inputs:
return False
source = inputs[slot] if isinstance(slot, int) and 0 <= slot < len(inputs) else inputs[0]
link = links.get(str(source.get("link")))
return visit(*link, seen) if link else False
return True
flags = 0
for name, bit in (("input_a", 1), ("input_b", 2)):
source = next((i for i in selector.get("inputs", []) if i.get("name") == name), {})
link = links.get(str(source.get("link")))
enabled = visit(*link, set()) if link else False
if enabled is None:
return None
if enabled:
flags |= bit
return flags
def normalize_exclusive_prompt(data):
"""Resolve flags before validation/cache keys, without changing workflow wires."""
prompt = data.get("prompt", {})
extra = data.get("extra_data", {}).get("extra_pnginfo")
if not isinstance(prompt, dict):
return data
for node_id, node in prompt.items():
if not isinstance(node, dict) or node.get("class_type") != "AdachiExclusiveSwitch":
continue
flags = _workflow_exclusive_flags(extra, node_id)
if flags is None:
continue
inputs = node.setdefault("inputs", {})
inputs["active_inputs"] = flags
for name, bit in (("input_a", 1), ("input_b", 2)):
if not flags & bit:
inputs.pop(name, None)
return data
class AdachiExclusiveSwitch:
"""Forward exactly one enabled branch, without evaluating the ignored branch."""
@@ -632,6 +714,7 @@ class AdachiExclusiveSwitch:
# including bypassed transform nodes whose links otherwise pass through.
"active_inputs": ("INT", {"default": -1, "min": -1, "max": 3}),
},
"hidden": {"prompt": "PROMPT", "dynprompt": "DYNPROMPT", "unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("*",)
@@ -642,25 +725,45 @@ class AdachiExclusiveSwitch:
SEARCH_ALIASES = ["exclusive switch", "auto switch", "自动二选一", "互斥", "自动切换", "adachi"]
@staticmethod
def _selected_input(input_a, input_b, active_inputs):
def _selected_input(input_a, input_b, active_inputs, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
workflow_flags = _workflow_exclusive_flags(extra_pnginfo, unique_id)
if workflow_flags is not None:
active_inputs = workflow_flags
execution_node = None
if dynprompt is not None and unique_id is not None:
try:
execution_node = dynprompt.get_node(unique_id)
except KeyError:
pass
if execution_node is None and isinstance(prompt, dict) and unique_id is not None:
execution_node = prompt.get(str(unique_id), prompt.get(unique_id))
if isinstance(execution_node, dict) and isinstance(execution_node.get("inputs"), dict):
# Get/Set and mute/bypass resolution may remove a connection after
# widgets serialize. Trust the actual submitted connections when
# only one (or none) survives, regardless of stale frontend flags.
connected = execution_node["inputs"]
actual = int("input_a" in connected) | (int("input_b" in connected) << 1)
if actual != 3:
active_inputs = actual if workflow_flags is None else actual & workflow_flags
if active_inputs == -1:
# Raw API prompts need no frontend state: simply omit the unused input.
active_inputs = int(input_a is not _MISSING_INPUT) | (int(input_b is not _MISSING_INPUT) << 1)
if active_inputs not in (1, 2):
raise ValueError(
"Auto Exclusive Switch requires exactly one enabled input. "
"Bypass one branch or disconnect it; both enabled / both absent are invalid."
"Bypass one branch or disconnect it; both enabled / both absent are invalid. "
f"Received enabled-state={active_inputs}."
)
name = "input_a" if active_inputs == 1 else "input_b"
if (input_a if active_inputs == 1 else input_b) is _MISSING_INPUT:
raise ValueError(f"The enabled branch {name} has no input.")
return name
def check_lazy_status(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1):
return [self._selected_input(input_a, input_b, active_inputs)]
def check_lazy_status(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
return [self._selected_input(input_a, input_b, active_inputs, prompt, dynprompt, unique_id, extra_pnginfo)]
def select(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1):
selected = self._selected_input(input_a, input_b, active_inputs)
def select(self, input_a=_MISSING_INPUT, input_b=_MISSING_INPUT, active_inputs=-1, prompt=None, dynprompt=None, unique_id=None, extra_pnginfo=None):
selected = self._selected_input(input_a, input_b, active_inputs, prompt, dynprompt, unique_id, extra_pnginfo)
value = input_a if selected == "input_a" else input_b
if value is None:
raise ValueError(f"The enabled branch {selected} returned no data.")
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "adachi-inpaint-workbench"
version = "0.5.7"
version = "0.5.12"
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
+8 -1
View File
@@ -12,5 +12,12 @@ assert.deepEqual(appendComparison(legacy, {originals: ["c"], results: [5]}), {
assert.deepEqual(appendComparison(undefined, first), first);
assert.deepEqual(appendComparison(first, {}), first);
assert.deepEqual(appendComparison({}, {results: ["orphan"]}), {originals: [], results: []});
assert.deepEqual(appendComparison(JSON.parse(JSON.stringify(second)), first).results, ["result-a", "result-b", "result-c", "result-a"]);
assert.deepEqual(appendComparison(JSON.parse(JSON.stringify(second)), first).results, ["result-a", "result-b", "result-c"], "Cached execution replay must not count twice");
const preview = {filename: "preview_001.png", subfolder: "", type: "temp"};
const one = {originals: ["source-a"], results: [preview]};
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview}]}).results.length, 1);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, filename: "preview_002.png"}]}).results.length, 2);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, subfolder: "other"}]}).results.length, 2);
assert.equal(appendComparison(one, {originals: ["source-a"], results: [{...preview, type: "output"}]}).results.length, 2);
assert.deepEqual(appendComparison({originals: ["source-a", "source-a"], results: [preview, {...preview}]}), one, "Repair duplicates already saved in workflows");
console.log("Comparison history checks passed");
+93
View File
@@ -1,5 +1,6 @@
"""The selector must preserve arbitrary values and never silently prefer a branch."""
import importlib.util
import copy
from pathlib import Path
import unittest
@@ -14,6 +15,17 @@ class ExclusiveSwitchTests(unittest.TestCase):
def setUp(self):
self.node = backend.AdachiExclusiveSwitch()
def workflow(self):
return {"nodes": [
{"id": 1, "type": "CLIPTextEncode", "mode": 4},
{"id": 2, "type": "CLIPTextEncode", "mode": 0},
{"id": 3, "type": "SetNode", "mode": 4, "widgets_values": ["p_0"], "inputs": [{"link": 1}]},
{"id": 4, "type": "SetNode", "mode": 0, "widgets_values": ["p"], "inputs": [{"link": 2}]},
{"id": 5, "type": "GetNode", "mode": 0, "widgets_values": ["p_0"]},
{"id": 6, "type": "GetNode", "mode": 0, "widgets_values": ["p"]},
{"id": 532, "type": "AdachiExclusiveSwitch", "inputs": [{"name": "input_a", "link": 3}, {"name": "input_b", "link": 4}]},
], "links": [[1, 1, 0, 3, 0, "*"], [2, 2, 0, 4, 0, "*"], [3, 5, 0, 532, 0, "*"], [4, 6, 0, 532, 1, "*"]]}
def test_single_connected_branch_is_requested(self):
self.assertEqual(self.node.check_lazy_status(input_a=None), ["input_a"])
self.assertEqual(self.node.check_lazy_status(input_b=None), ["input_b"])
@@ -42,6 +54,87 @@ class ExclusiveSwitchTests(unittest.TestCase):
self.assertIs(self.node.select(input_a=value)[0], value)
self.assertIs(self.node.select(input_b=value)[0], value)
def test_submitted_single_connection_overrides_stale_flags(self):
for name in ("input_a", "input_b"):
prompt = {"532": {"inputs": {name: ["source", 0], "active_inputs": 3}}}
for stale in (-1, 0, 1, 2, 3):
with self.subTest(name=name, stale=stale):
args = {name: None, "active_inputs": stale, "prompt": prompt, "unique_id": "532"}
self.assertEqual(self.node.check_lazy_status(**args), [name])
value = object()
args[name] = value
self.assertIs(self.node.select(**args)[0], value)
def test_submitted_empty_or_ambiguous_connections_still_fail(self):
for inputs in ({}, {"input_a": ["a", 0], "input_b": ["b", 0]}):
with self.subTest(inputs=inputs), self.assertRaisesRegex(ValueError, "exactly one"):
self.node.check_lazy_status(active_inputs=3, prompt={"1": {"inputs": inputs}}, unique_id="1")
prompt = {"1": {"inputs": {"input_a": ["a", 0], "input_b": ["b", 0]}}}
self.assertEqual(self.node.check_lazy_status(input_a=None, input_b=None, active_inputs=2, prompt=prompt, unique_id="1"), ["input_b"])
def test_dynamic_subgraph_connection_is_authoritative(self):
class DynamicPrompt:
def get_node(self, node_id):
self_id = "subgraph:532"
if node_id != self_id:
raise KeyError(node_id)
return {"inputs": {"input_b": ["nested-source", 0]}}
self.assertEqual(self.node.check_lazy_status(input_b=None, active_inputs=3,
prompt={}, dynprompt=DynamicPrompt(), unique_id="subgraph:532"), ["input_b"])
def test_enabled_gets_follow_ignored_set_and_encoder_in_workflow(self):
workflow = self.workflow()
args = dict(input_a=None, input_b=None, active_inputs=3, unique_id="532", extra_pnginfo={"workflow": workflow})
self.assertEqual(self.node.check_lazy_status(**args), ["input_b"])
for stale in range(-1, 4):
self.assertEqual(self.node.check_lazy_status(**{**args, "active_inputs": stale}), ["input_b"])
for node in workflow["nodes"][:4]:
node["mode"] = 0 if node["mode"] == 4 else 4
self.assertEqual(self.node.check_lazy_status(**args), ["input_a"])
def test_ignored_set_alone_or_encoder_alone_disables_route(self):
for source_mode, set_mode in ((4, 0), (0, 4), (2, 0), (0, 2)):
workflow = self.workflow()
workflow["nodes"][0]["mode"] = source_mode
workflow["nodes"][2]["mode"] = set_mode
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 2)
def test_workflow_ambiguous_empty_cycle_missing_set_are_not_selected(self):
workflow = self.workflow()
for node in workflow["nodes"][:4]:
node["mode"] = 0
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 3)
for node in workflow["nodes"][:4]:
node["mode"] = 4
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 0)
workflow = self.workflow()
workflow["nodes"][2]["mode"] = 0
workflow["links"][0][1] = 5
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 2)
workflow["nodes"][4]["widgets_values"] = ["missing"]
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "532"), 2)
def test_prompt_normalization_before_cache_preserves_workflow(self):
workflow = self.workflow()
saved = copy.deepcopy(workflow)
request = {"prompt": {"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"input_a": ["a", 0], "input_b": ["b", 0], "active_inputs": 3}}},
"extra_data": {"extra_pnginfo": {"workflow": workflow}}}
self.assertIs(backend.normalize_exclusive_prompt(request), request)
self.assertEqual(request["prompt"]["532"]["inputs"], {"input_b": ["b", 0], "active_inputs": 2})
self.assertEqual(workflow, saved)
plain = {"prompt": {"532": {"class_type": "AdachiExclusiveSwitch", "inputs": {"input_a": ["a", 0]}}}}
original = copy.deepcopy(plain)
backend.normalize_exclusive_prompt(plain)
self.assertEqual(plain, original)
def test_scoped_subgraph_and_dictionary_links(self):
child = self.workflow()
child["id"] = "child"
child["links"] = [dict(zip(("id", "origin_id", "origin_slot", "target_id", "target_slot", "type"), link)) for link in child["links"]]
workflow = {"nodes": [{"id": 349, "type": "child"}], "definitions": {"subgraphs": [child]}}
self.assertEqual(backend._workflow_exclusive_flags({"workflow": workflow}, "349:532"), 2)
self.assertIsNone(backend._workflow_exclusive_flags({"workflow": workflow}, "532"))
if __name__ == "__main__":
unittest.main()
+36 -11
View File
@@ -35,7 +35,10 @@ export class ImageCompareView {
this.canvas.oncontextmenu = event => event.preventDefault();
this.canvas.addEventListener("pointerenter", event => { this.hovered = true; this.move(event); this.schedule(); });
this.canvas.addEventListener("pointerleave", () => { this.hovered = false; this.schedule(); });
this.stage.append(this.canvas);
this.divider = element("div");
Object.assign(this.divider.style, { position: "absolute", background: "#ffffffb3", pointerEvents: "none" });
this.divider.hidden = true;
this.stage.append(this.canvas, this.divider);
this.empty = element("div", "dd-empty", T("Connect an original image and a result, then run the workflow")); this.stage.append(this.empty);
const adoption = element("div", "dd-row dd-adoption");
adoption.append(element("span", "dd-compare-label", T("Adopt into")));
@@ -150,21 +153,43 @@ export class ImageCompareView {
render() {
const t = this.transform(), ratio = Math.min(devicePixelRatio || 1, 2);
if (!t.width || !t.height) return;
const width = Math.round(t.width * ratio), height = Math.round(t.height * ratio);
// Graph zoom changes the screen rect, not the amount of source detail.
// Never allocate a huge upscaled bitmap just to magnify existing pixels.
const rasterRatio = Math.min(ratio, this.ready ? 1 / t.scale : ratio,
8192 / t.width, 8192 / t.height, Math.sqrt(16 * 1024 * 1024 / (t.width * t.height)));
const width = Math.max(1, Math.round(t.width * rasterRatio)), height = Math.max(1, Math.round(t.height * rasterRatio));
if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; }
const ctx = this.canvas.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, width, height);
if (!this.ready) return;
ctx.setTransform(t.scale * ratio, 0, 0, t.scale * ratio, t.x * ratio, t.y * ratio);
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
if (!this.hovered) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
this.divider.hidden = !this.ready || !this.hovered;
if (!this.ready) { ctx.clearRect(0, 0, width, height); this.lastPaint = null; return; }
const split = this.split * this.before.width;
const lineX = Math.round((t.x + split * t.scale) * rasterRatio) + .5;
if (this.hovered) {
const screenScale = t.width / this.canvas.clientWidth || 1;
Object.assign(this.divider.style, {
left: `${(t.x + split * t.scale) / screenScale}px`, top: `${t.y / screenScale}px`,
height: `${this.before.height * t.scale / screenScale}px`, width: `${1 / screenScale}px`,
});
}
const key = [this.version, width, height, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio].join(":");
const previous = this.lastPaint;
ctx.save();
// While hovering, pixels outside the old/new divider positions are
// unchanged. Clip the redraw to that strip rather than repainting all.
if (previous?.key === key && previous.hovered && this.hovered) {
const left = Math.max(0, Math.floor(Math.min(previous.lineX, lineX)) - 2);
const right = Math.min(width, Math.ceil(Math.max(previous.lineX, lineX)) + 2);
ctx.beginPath(); ctx.rect(left, 0, right - left, height); ctx.clip();
}
ctx.clearRect(0, 0, width, height);
ctx.setTransform(t.scale * rasterRatio, 0, 0, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio);
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
this.lastPaint = { key, hovered: this.hovered, lineX };
if (!this.hovered) { ctx.restore(); return; }
ctx.save(); ctx.beginPath(); ctx.rect(split, 0, this.before.width - split, this.before.height); ctx.clip();
ctx.drawImage(this.before, 0, 0); ctx.restore();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const lineX = Math.round((t.x + split * t.scale) * ratio) + .5;
ctx.beginPath(); ctx.moveTo(lineX, t.y * ratio); ctx.lineTo(lineX, (t.y + this.before.height * t.scale) * ratio);
ctx.strokeStyle = "#ffffffb3"; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
}
destroy() {
+12 -1
View File
@@ -11,5 +11,16 @@ export function appendComparison(previous, incoming) {
};
};
const before = pairs(previous), next = pairs(incoming);
return { originals: [...before.originals, ...next.originals], results: [...before.results, ...next.results] };
const history = { originals: [], results: [] }, seen = new Set();
for (const data of [before, next]) data.results.forEach((result, index) => {
// Cached execution notifications can replay the same preview. Its file
// identity is stable, while a genuinely new generation gets a new file.
const key = typeof result === "object" && result !== null
? JSON.stringify([result.type || "temp", result.subfolder || "", result.filename])
: JSON.stringify(result);
if (seen.has(key)) return;
seen.add(key);
history.originals.push(data.originals[index]); history.results.push(result);
});
return history;
}
+21 -5
View File
@@ -1,7 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ImageCompareView } from "./compare.js?v=0.5.7";
import { appendComparison } from "./compare_history.js?v=0.5.7";
import { ImageCompareView } from "./compare.js?v=0.5.9";
import { appendComparison } from "./compare_history.js?v=0.5.8";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
@@ -49,6 +49,14 @@ app.registerExtension({
const blob = await response.blob();
if (blob.size > 64 * 1024 * 1024) throw new Error(T("Result image exceeds 64 MiB; downscale it first"));
await target.adachiUseResult(new File([blob], `adachi-result-${Date.now()}.png`, { type: "image/png" }));
// Adoption starts a new round. Keep the accepted image visible
// as 1/1, then replace it with the next round's first outputs.
const data = { originals: [image], results: [image] };
node.properties.adachi_compare = data;
node.properties.adachi_result_index = 0;
node.properties.adachi_compare_new_round = true;
await compare.setData(data, 0);
app.graph?.change?.();
},
});
node.adachiCompare = compare;
@@ -62,18 +70,26 @@ app.registerExtension({
node.onExecuted = function (output) {
const result = executed?.apply(this, arguments);
if (!output?.adachi_originals?.length || !output?.adachi_results?.length) return result;
const data = appendComparison(node.properties.adachi_compare, {
const previous = node.properties.adachi_compare_new_round
? appendComparison() : appendComparison(node.properties.adachi_compare);
const data = appendComparison(previous, {
originals: output.adachi_originals, results: output.adachi_results,
});
node.properties.adachi_compare = data;
node.properties.adachi_result_index = data.results.length - 1;
node.properties.adachi_compare_new_round = false;
node.properties.adachi_result_index = data.results.length > previous.results.length
? data.results.length - 1 : Math.max(0, Math.min(data.results.length - 1, node.properties.adachi_result_index || 0));
compare.setData(data, node.properties.adachi_result_index);
return result;
};
const configured = node.onConfigure;
node.onConfigure = function () {
const result = configured?.apply(this, arguments);
if (node.properties.adachi_compare) compare.setData(node.properties.adachi_compare, node.properties.adachi_result_index || 0);
if (node.properties.adachi_compare) {
node.properties.adachi_compare = appendComparison(node.properties.adachi_compare);
node.properties.adachi_result_index = Math.max(0, Math.min(node.properties.adachi_compare.results.length - 1, node.properties.adachi_result_index || 0));
compare.setData(node.properties.adachi_compare, node.properties.adachi_result_index);
}
return result;
};
const removed = node.onRemoved;
+46 -18
View File
@@ -24,6 +24,8 @@ export class InlineMaskEditor {
this.mask = makeCanvas();
this.baseMask = makeCanvas();
this.overlay = makeCanvas();
this.scene = makeCanvas();
this.maskRevision = 0;
this.tool = "brush";
this.view = "overlay";
this.brushSize = 64;
@@ -92,7 +94,7 @@ export class InlineMaskEditor {
head.append(this.sizeLabel);
this.root.append(head);
const files = el("div", "dd-row dd-files");
this.loadButton = this.button(files, T("Load image"), T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
this.loadButton = this.button(files, T("Upload"), T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
this.filename = el("span", "dd-filename", T("Drop an image onto the canvas"));
files.append(this.filename);
this.root.append(files);
@@ -171,6 +173,8 @@ export class InlineMaskEditor {
this.zoomLabel = el("span", "dd-zoom", "100%");
footer.append(this.zoomLabel);
this.fitButton = this.button(footer, T("Fit"), T("Fit the image to the canvas"), () => this.fit());
this.keepButton = this.button(footer, "", T("Keep"), event => { event.stopPropagation(); this.setKeepFit(!this.keepFit); });
this.keepButton.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-4 1-4 4 1 4-3 3-3-6-6-3 3-3 4 1 4-4zM8 16l-6 6"/></svg>';
// The inline button must not select/bring forward the ComfyUI node:
// newer frontends can remeasure its DOM widget on that bubbling click.
const fitAction = this.fitButton.onclick;
@@ -194,6 +198,7 @@ export class InlineMaskEditor {
head.remove();
this.controls.append(info, tools, settings, actions, footer, embedRow);
this.loadButton.after(this.fitButton);
this.fitButton.after(this.keepButton);
// These are the original live controls; the sampler mounts this
// same DOM tree so brush state and callbacks are preserved.
}
@@ -315,6 +320,8 @@ export class InlineMaskEditor {
}
updateButtons() {
this.keepButton?.classList.toggle("dd-active", !!this.keepFit);
this.keepButton?.setAttribute("aria-pressed", String(!!this.keepFit));
this.stage?.classList.toggle("dd-erasing", !!(this.altErase || this.tool === "erase" || this.stroke?.type === "erase"));
for (const [name, button] of Object.entries(this.toolButtons || {})) {
const active = name === (this.altErase ? "erase" : this.tool);
@@ -331,6 +338,7 @@ export class InlineMaskEditor {
dimensions() {
const r = this.canvas.getBoundingClientRect();
if (this.keepFit) { this.zoom = 1; this.pan = { x: 0, y: 0 }; }
if (this.viewport?.width && this.viewport?.height && r.width && r.height) {
this.pan.x *= r.width / this.viewport.width;
this.pan.y *= r.height / this.viewport.height;
@@ -348,6 +356,7 @@ export class InlineMaskEditor {
pointerDown(event) {
if (!this.ready || this.pointerId != null) return;
if (this.keepFit && (this.space || (!event.altKey && this.tool === "pan") || event.button === 1)) return;
event.preventDefault(); event.stopPropagation(); this.root.focus({ preventScroll: true });
this.altErase = !!event.altKey; this.updateButtons();
this.pointerId = event.pointerId;
@@ -415,6 +424,7 @@ export class InlineMaskEditor {
}
paintSegment(ctx, command, a, b) {
if (ctx.canvas === this.mask) this.maskRevision++;
ctx.save();
ctx.globalCompositeOperation = command.type === "erase" ? "destination-out" : "source-over";
ctx.globalAlpha = command.opacity / 100;
@@ -483,6 +493,7 @@ export class InlineMaskEditor {
redo() { if (this.future.length && !this.stroke) { this.history.push(this.future.pop()); this.replay(); } }
changed() {
this.maskRevision++;
this.dirty = true;
this.flush();
this.schedule();
@@ -525,6 +536,13 @@ export class InlineMaskEditor {
fit() { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }
setKeepFit(enabled, notify = true) {
this.keepFit = !!enabled;
if (this.keepFit) this.fit();
this.updateButtons(); this.schedule();
if (notify) this.options.onKeepFit?.(this.keepFit);
}
setBrushSize(value) {
this.brushSizeInput.value = Math.max(1, Math.min(512, Math.round(value)));
this.brushSizeInput.dispatchEvent(new Event("input"));
@@ -533,6 +551,7 @@ export class InlineMaskEditor {
wheel(event) {
if (!this.ready) return;
event.preventDefault(); event.stopPropagation();
if (this.keepFit && !event.ctrlKey) { this.options.onGraphWheel?.(event); return; }
if (event.ctrlKey) {
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1);
if (delta) {
@@ -569,24 +588,33 @@ export class InlineMaskEditor {
render() {
const rect = this.canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const ratio = Math.min(devicePixelRatio || 1, 2);
const w = Math.round(rect.width * ratio), h = Math.round(rect.height * ratio);
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; this.overlay.width = w; this.overlay.height = h; }
const ctx = this.canvas.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h);
if (!this.ready) return;
const d = this.dimensions();
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
if (this.view !== "mask") ctx.drawImage(this.image, 0, 0);
else { ctx.fillStyle = "black"; ctx.fillRect(0, 0, this.image.width, this.image.height); }
if (this.view !== "image") {
const overlay = this.overlay.getContext("2d");
overlay.setTransform(1, 0, 0, 1, 0, 0); overlay.clearRect(0, 0, w, h);
overlay.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
overlay.drawImage(this.mask, 0, 0);
if (this.view === "overlay") { overlay.globalCompositeOperation = "source-in"; overlay.fillStyle = "#ffad45"; overlay.fillRect(0, 0, this.mask.width, this.mask.height); overlay.globalCompositeOperation = "source-over"; }
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = this.view === "mask" ? 1 : this.overlayOpacity / 100; ctx.drawImage(this.overlay, 0, 0); ctx.globalAlpha = 1;
const ratio = Math.min(devicePixelRatio || 1, 2, this.ready ? 1 / d.scale : 2,
8192 / rect.width, 8192 / rect.height, Math.sqrt(8 * 1024 * 1024 / (rect.width * rect.height)));
const w = Math.max(1, Math.round(rect.width * ratio)), h = Math.max(1, Math.round(rect.height * ratio));
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; this.overlay.width = w; this.overlay.height = h; }
const display = this.canvas.getContext("2d");
if (!this.ready) { display.clearRect(0, 0, w, h); this.sceneKey = null; return; }
const key = [this.loadVersion, this.maskRevision, w, h, d.scale * ratio, d.x * ratio, d.y * ratio, this.view, this.overlayOpacity].join(":");
if (key !== this.sceneKey) {
if (this.scene.width !== w || this.scene.height !== h) { this.scene.width = w; this.scene.height = h; }
const ctx = this.scene.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h);
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
if (this.view !== "mask") ctx.drawImage(this.image, 0, 0);
else { ctx.fillStyle = "black"; ctx.fillRect(0, 0, this.image.width, this.image.height); }
if (this.view !== "image") {
const overlay = this.overlay.getContext("2d");
overlay.setTransform(1, 0, 0, 1, 0, 0); overlay.clearRect(0, 0, w, h);
overlay.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
overlay.drawImage(this.mask, 0, 0);
if (this.view === "overlay") { overlay.globalCompositeOperation = "source-in"; overlay.fillStyle = "#ffad45"; overlay.fillRect(0, 0, this.mask.width, this.mask.height); overlay.globalCompositeOperation = "source-over"; }
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = this.view === "mask" ? 1 : this.overlayOpacity / 100; ctx.drawImage(this.overlay, 0, 0); ctx.globalAlpha = 1;
}
this.sceneKey = key;
}
const ctx = display;
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h); ctx.drawImage(this.scene, 0, 0);
ctx.setTransform(ratio * d.scale, 0, 0, ratio * d.scale, ratio * d.x, ratio * d.y);
if (this.stroke && ["rect", "lasso"].includes(this.stroke.type)) {
ctx.save(); ctx.strokeStyle = "#ffcf85"; ctx.lineWidth = 1.5 / d.scale; ctx.setLineDash([5 / d.scale, 4 / d.scale]);
@@ -613,7 +641,7 @@ export class InlineMaskEditor {
window.removeEventListener("keydown", this.modifierDown, true);
window.removeEventListener("keyup", this.modifierUp, true);
window.removeEventListener("blur", this.modifierUp);
for (const canvas of [this.mask, this.baseMask, this.overlay, this.canvas]) canvas.width = canvas.height = 0;
for (const canvas of [this.mask, this.baseMask, this.overlay, this.scene, this.canvas]) canvas.width = canvas.height = 0;
this.history = []; this.future = []; this.image = null; this.controls?.remove(); this.root.remove();
}
}
+2
View File
@@ -86,6 +86,8 @@ const ZH = {
"Image": "原图",
"Mask": "蒙版",
"Fit": "适应",
"Keep": "保持",
"Upload": "上传",
"Fit the image to the canvas": "适应画布",
"File is too large (max 64 MB)": "图片文件不能超过 64 MB",
"Loading image…": "正在载入原图…",
+4 -2
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { InlineMaskEditor } from "./editor.js?v=0.5.5";
import { InlineMaskEditor } from "./editor.js?v=0.5.10";
import { installInpaintWorkbench } from "./inpaint_node.js?v=0.5.5";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { routeWheelOutsideImage } from "./wheel_routing.js";
@@ -87,6 +87,7 @@ app.registerExtension({
compact: true,
onChange(data) { if (maskWidget && !restoring) maskWidget.value = data; },
onDirty() { markChanged(); },
onKeepFit(enabled) { node.properties.adachi_keep_fit = enabled; markChanged(); },
onImage() { if (!restoring || !node.properties.adachi_compact_ui) fitNodeToImage(); },
onGraphWheel: event => app.canvas?.processMouseWheel(event),
async onUpload(file, { preserveMask = false } = {}) {
@@ -115,7 +116,7 @@ app.registerExtension({
markChanged();
},
});
editor.root.dataset.adachiVersion = "0.5.5";
editor.root.dataset.adachiVersion = "0.5.10";
node.adachiEditor = editor;
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
node.adachiUseResult = async file => {
@@ -192,6 +193,7 @@ app.registerExtension({
const previousConfigure = node.onConfigure;
node.onConfigure = function () {
const configured = previousConfigure?.apply(this, arguments);
editor.setKeepFit(!!node.properties.adachi_keep_fit, false);
cancelAnimationFrame(restoreFrame); restoreFrame = requestAnimationFrame(restore);
return configured;
};