Compare commits

...
2 Commits
11 changed files with 146 additions and 36 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## 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.
+7 -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,7 @@ 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.
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, 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.
## Using a standard sampling workflow
@@ -212,7 +215,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 和参数键名,所以已保存的工作流不受语言切换影响。
+6 -4
View File
@@ -1,10 +1,12 @@
# Git versions and rollback
The current local version is **v0.5.9**. 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.11**. 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.9` | Bound comparison bitmap allocations and redraw only the swept divider strip |
| `v0.5.11` | 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 |
@@ -21,13 +23,13 @@ To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach local-before-v0.5.9
git switch --detach local-before-v0.5.11
```
Return to this version with:
```powershell
git switch --detach v0.5.9
git switch --detach v0.5.11
```
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
+4
View File
@@ -1,5 +1,7 @@
# Verification record
- 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.
@@ -51,6 +53,8 @@ Environment: Windows, local ComfyUI 0.19.3, ComfyUI frontend 1.42.11, PyTorch 2.
- 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.
+24 -6
View File
@@ -632,6 +632,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"},
}
RETURN_TYPES = ("*",)
@@ -642,25 +643,42 @@ 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):
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 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):
return [self._selected_input(input_a, input_b, active_inputs, prompt, dynprompt, unique_id)]
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):
selected = self._selected_input(input_a, input_b, active_inputs, prompt, dynprompt, unique_id)
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.9"
version = "0.5.11"
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
+28
View File
@@ -42,6 +42,34 @@ 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"])
if __name__ == "__main__":
unittest.main()
+11 -1
View File
@@ -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,11 +70,13 @@ app.registerExtension({
node.onExecuted = function (output) {
const result = executed?.apply(this, arguments);
if (!output?.adachi_originals?.length || !output?.adachi_results?.length) return result;
const previous = 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_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);
+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;
};