Compare commits

...
6 Commits
Author SHA1 Message Date
adachi 3030531275 Remove the ✓ merge step; right-click reselects directly
Adoption returns to the repaint mask (option A) and layer blending stays opt-in via ◐. Right-click/Escape now flattens any remaining layer and releases the region in one action, so the separate ✓ commit button is gone.
2026-10-17 14:44:16 +08:00
adachi f76b5aa0fd Fix the inline status hint staying visible
The earlier status() rework let non-error hints stay visible, so "Orange is the
painted mask" lingered on the canvas. Restore the simple rule that only errors
stay visible, keep an explicit notice() for the one case that must persist, and
bump the editor asset version so browsers fetch the corrected module.
2026-10-17 14:27:42 +08:00
adachi 175a063195 Restructure crop adoption and speed up change detection
Crop & Blend adoption now bakes the layer into the source at full quality and
returns to the repaint mask immediately, so the user can generate again right
away. Layer blending is entered explicitly through the blend button instead of
auto-entering after adoption.

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

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

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

The pin toggle previously forced the image back to a fitted view on every
measurement, so it could not hold a deliberately panned or zoomed position. It now
freezes whatever position is currently drawn, keeps panning and zooming available,
and still routes the plain wheel to the workflow canvas.
2026-10-17 13:30:26 +08:00
10 changed files with 118 additions and 45 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## 0.8.0
- Restructure adoption in Crop & Blend: after "Adopt this result" the layer is baked into the source at full quality and the editor returns to the repaint mask immediately, so you can generate again right away. The ◐ button now enters layer blending explicitly instead of auto-entering after adoption.
- Remove the ✓ merge step entirely: right-click (or Escape) now releases the selected region and flattens any remaining layer in one action, so there is no intermediate commit button between adopting a result and picking the next region.
- Speed up inline mask change detection: the source image is fingerprinted by size and mtime instead of hashing its full high-resolution bytes on every change/execution.
## 0.7.2
- Adopting a result no longer decodes the full-size image a second time: the already-decoded image is reused for the crop layer, cutting one download/decode round trip.
- Show a visible message when adoption is skipped because the preview is still loading, no target is selected, or the crop/layer state changed, instead of failing silently.
- Bump the asset versions for the crop, comparison and result-adoption modules.
## 0.7.1
- Uploading a new source image now keeps the painted mask, undo/redo history and viewport, the same way adopting a result does; only the source pixels change. A first upload still starts with an empty mask.
- When the new image has a different size the mask cannot be kept, so the image loads fresh and a notice explains that the mask was reset instead of failing the upload.
## 0.7.0
- Add a download button next to the inline upload button: it saves the current source image as a PNG at its own resolution. It stays disabled until an image is loaded.
- Change the pin toggle so it freezes the view exactly as it is drawn instead of re-fitting the image: panning and zooming stay available while pinned, and the pinned view survives canvas resizes.
- Keep routing the plain wheel to the workflow canvas while pinned, as before.
## 0.6.9
- Place the Clear button directly after the swap width/height control in Crop & Blend; in Mask mode it is the only visible button of that bar and stays at the bottom left of the canvas.
+6 -3
View File
@@ -333,10 +333,13 @@ class AdachiInlineMask:
digest.update(str(value).encode("utf-8"))
digest.update(b"\x00")
if not source_data:
# The source is a stable input file; hashing its size and mtime is
# enough to detect a replacement and avoids reading a full high-res
# PNG on every change/execution. Content-hash the bytes only when
# the metadata cannot be read (the loader will then raise anyway).
try:
with _input_path(image).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
stat = _input_path(image).stat()
digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("utf-8"))
except (ValueError, OSError):
return float("nan")
return digest.hexdigest()
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "adachi-inpaint-workbench"
version = "0.6.9"
version = "0.8.0"
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
+3 -1
View File
@@ -118,7 +118,9 @@ export class ImageCompareView {
async adopt() {
this.refreshTargets();
if (!this.ready || !this.targetSelect.value || this.busy) return;
if (this.busy) return;
if (!this.ready) { 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…"));
try {
+1 -1
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ImageCompareView } from "./compare.js?v=0.5.9";
import { ImageCompareView } from "./compare.js?v=0.7.2";
import { appendComparison } from "./compare_history.js?v=0.5.8";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
+35 -9
View File
@@ -95,6 +95,7 @@ export class InlineMaskEditor {
this.root.append(head);
const files = el("div", "dd-row dd-files");
this.loadButton = this.button(files, "↑", T("Upload an image, or drop / paste one"), () => this.imageInput.click(), "dd-primary");
this.downloadButton = this.button(files, "↓", T("Download the source image"), () => this.downloadSource(), "dd-primary");
this.filename = el("span", "dd-filename", T("Drop an image onto the canvas"));
files.append(this.filename);
this.root.append(files);
@@ -174,7 +175,7 @@ export class InlineMaskEditor {
this.zoomLabel = el("span", "dd-zoom", "100%");
footer.append(this.zoomLabel);
this.fitButton = this.button(footer, "⛶", 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 = this.button(footer, "", T("Pin the current view"), event => { event.stopPropagation(); this.setKeepFit(!this.keepFit); });
this.keepButton.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-4 1-4 4 1 4-3 3-3-6-6-3 3-3 4 1 4-4zM8 16l-6 6"/></svg>';
// The inline button must not select/bring forward the ComfyUI node:
// newer frontends can remeasure its DOM widget on that bubbling click.
@@ -201,6 +202,7 @@ export class InlineMaskEditor {
actions.addEventListener("keydown", event => this.keydown(event));
this.loadButton.after(this.fitButton);
this.fitButton.after(this.keepButton);
this.loadButton.after(this.downloadButton);
// These are the original live controls; the sampler mounts this
// same DOM tree so brush state and callbacks are preserved.
}
@@ -225,14 +227,24 @@ export class InlineMaskEditor {
status(message, error = false) {
this.message.textContent = message;
this.message.classList.toggle("dd-error", error);
// Only errors stay visible; transient hints and progress lines hide.
this.message.hidden = !error;
}
notice(message) {
// An explicit non-error notice that should remain visible.
this.message.textContent = message;
this.message.classList.remove("dd-error");
this.message.hidden = false;
}
async upload(file) {
if (file.size > 64 * 1024 * 1024) { this.status(T("File is too large (max 64 MB)"), true); return; }
try {
this.status(T("Loading image…"));
await this.options.onUpload?.(file);
// Keep the painted mask when a new source replaces the current one,
// the same way adopting a result does. A first upload starts empty.
await this.options.onUpload?.(file, { preserveMask: !!this.image });
} catch (error) { this.status(error.message, true); }
}
@@ -285,10 +297,10 @@ export class InlineMaskEditor {
async replaceSource(url, name = "") {
const version = ++this.loadVersion;
const image = await loadImage(url);
if (version !== this.loadVersion || this.destroyed) return;
if (image.width !== this.mask.width || image.height !== this.mask.height) {
throw new Error(T("The result size must match the source image to preserve the mask"));
}
if (version !== this.loadVersion || this.destroyed) return false;
// A different size cannot keep the painted mask; report it so the caller
// can fall back to a fresh load instead of failing the upload.
if (image.width !== this.mask.width || image.height !== this.mask.height) return false;
// Replacing only the source preserves mask pixels, the base snapshot,
// command history, redo history, active strokes and the current view.
this.flush();
@@ -296,6 +308,7 @@ export class InlineMaskEditor {
this.filename.textContent = name || T("Embedded image");
this.filename.title = this.filename.textContent;
this.updateButtons(); this.schedule();
return true;
}
importMask(ctx, image) {
@@ -325,6 +338,7 @@ export class InlineMaskEditor {
updateButtons() {
this.keepButton?.classList.toggle("dd-active", !!this.keepFit);
this.keepButton?.setAttribute("aria-pressed", String(!!this.keepFit));
if (this.downloadButton) this.downloadButton.disabled = !this.image;
this.stage?.classList.toggle("dd-erasing", !!(this.altErase || this.tool === "erase" || this.stroke?.type === "erase"));
for (const [name, button] of Object.entries(this.toolButtons || {})) {
const active = name === (this.altErase ? "erase" : this.tool);
@@ -341,7 +355,6 @@ 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;
@@ -360,7 +373,6 @@ export class InlineMaskEditor {
pointerDown(event) {
if (event.button === 2) { event.preventDefault(); event.stopPropagation(); return; }
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;
@@ -556,11 +568,25 @@ export class InlineMaskEditor {
const link = el("a"); link.download = "adachi-mask.png"; link.href = this.flush(); link.click();
}
downloadSource() {
if (!this.image) return;
const out = makeCanvas();
out.width = this.image.naturalWidth || this.image.width;
out.height = this.image.naturalHeight || this.image.height;
out.getContext("2d").drawImage(this.image, 0, 0);
const label = String(this.filename.textContent || "").split(/[\\/]/).pop().replace(/\.[^.]+$/, "");
const link = el("a");
link.download = `${label || "adachi-source"}.png`;
link.href = out.toDataURL("image/png");
link.click();
out.width = out.height = 0;
}
fit() { this.zoom = 1; this.pan = { x: 0, y: 0 }; this.schedule(); }
setKeepFit(enabled, notify = true) {
// Pin freezes the view exactly as drawn; it must not re-fit the image.
this.keepFit = !!enabled;
if (this.keepFit) this.fit();
this.updateButtons(); this.schedule();
if (notify) this.options.onKeepFit?.(this.keepFit);
}
+7 -2
View File
@@ -19,6 +19,9 @@ const ZH = {
"This result belongs to another crop; generate a result for the current region": "该结果来自其他选区,请为当前选区重新生成",
"Result dimensions must match the selected crop": "结果宽高必须与当前裁切区域一致",
"The crop changed while adopting the result": "采用过程中选区已改变,请重新生成结果",
"The result was not applied; the crop or layer state changed": "结果未应用,选区或图层状态已改变",
"The result preview is still loading": "结果预览仍在加载中",
"Select an inline mask to adopt into": "请选择要回填的内联蒙版",
"Painting tools and mask settings": "绘图工具与蒙版设置",
"Browser draft storage was full. The source was saved as an input file; the image, masks and layers are preserved.": "浏览器草稿空间不足,已将原图保存为图片文件;图像、蒙版和图层均已保留。",
"Editing mode": "编辑模式",
@@ -111,6 +114,8 @@ const ZH = {
"Mask": "蒙版",
"Fit": "适应",
"Keep": "保持",
"Pin the current view": "固定当前视图",
"Download the source image": "下载原图",
"Upload": "上传",
"Fit the image to the canvas": "适应画布",
"File is too large (max 64 MB)": "图片文件不能超过 64 MB",
@@ -122,8 +127,8 @@ const ZH = {
// inline_inpaint.js
"The inline mask node was removed": "内联蒙版节点已移除",
"Adopted the result as the new source image; the mask and undo history were preserved": "已采用重绘结果作为新原图,蒙版与撤销记录已保留",
"The result size must match the source image to preserve the mask": "为保留蒙版,重绘结果尺寸必须与原图一致",
"Wait for the source image to finish loading before adopting a result": "请等待原图加载完成后再采用结果",
"The new image has a different size, so the mask was reset": "新图尺寸与原图不同,蒙版已重置",
"Wait for the current image to finish loading": "请等待当前图片加载完成",
"Drag to resize canvas height": "上下拖动调整画布高度",
"Resize canvas height": "调整画布高度",
"Upload failed": "上传失败",
+30 -21
View File
@@ -1,7 +1,7 @@
import { CROP_PRESETS, cropAt } from "./crop_geometry.js?v=0.6.2";
import { T } from "./i18n.js";
import { syncCropDimensions } from "./parameter_bridge.js?v=0.6.4";
import { installResultAdoption } from "./result_adoption.js?v=0.6.4";
import { installResultAdoption } from "./result_adoption.js?v=0.7.2";
const fields = ["mask", "baseMask", "history", "future", "lastMask", "dirty"];
const canvas = (w, h) =>
@@ -93,14 +93,17 @@ export class InlineCropController {
e.preventDefault();
e.stopPropagation();
editor.root.focus({ preventScroll: true });
if (this.layer && this.region.locked && !editor.stroke && editor.pointerId == null) {
// Hold-right preview only makes sense while adjusting the blend layer.
// After adoption the layer is already baked, so right-click reselects
// (reselect flattens any remaining layer first).
if (this.target === "blend" && this.layer && this.region.locked && !editor.stroke && editor.pointerId == null) {
this.previewOriginal = true;
this.originalPreviewPointer = e.pointerId;
editor.canvas.setPointerCapture(e.pointerId);
editor.cursor = null;
this.revision++;
editor.schedule();
} else if (!this.layer && this.region.locked) this.run(() => this.reselect());
} else if (this.region.locked) this.run(() => this.reselect());
return;
}
if (
@@ -267,19 +270,19 @@ export class InlineCropController {
this.blendButton = button(
"◐",
"Edit layer blending: brush keeps the new result; Alt restores original pixels. Hold right-click to preview the original, release to return.",
() => {
this.activate("blend");
this.changed();
},
);
this.commitButton = button("✓", "Merge the visible result and blend mask into the source image. After merging, right-click releases the selected region.", () =>
this.flatten(),
() =>
this.run(async () => {
// Blending is opt-in after adoption: build the blend mask on first
// use, then switch the editor into the blend layer.
if (!this.contexts.blend) await this.makeBlend(this.widgets.blend_data.value);
this.activate("blend");
this.changed();
}),
);
// Move the original live button, retaining its history and disabled state.
this.undoButton = e.undoButton;
this.undoButton.textContent = "↶";
this.undoButton.title = T("Undo the last stroke in the active repaint or blend mask (Ctrl+Z). Redo with Ctrl+Shift+Z.");
this.commitButton.after(this.undoButton);
this.custom = make("div", "dd-crop-custom");
this.custom.hidden = true;
for (const key of ["cropWidth", "cropHeight"]) {
@@ -322,14 +325,13 @@ export class InlineCropController {
this.modeSelect.value = this.mode;
// No hover text on the painting canvas; the bottom controls carry the hints.
this.editor.canvas.removeAttribute("title");
for (const control of [this.preset, this.swapButton, this.maskButton, this.blendButton, this.commitButton]) {
for (const control of [this.preset, this.swapButton, this.maskButton, this.blendButton]) {
control.hidden = this.mode !== "crop";
}
this.custom.hidden = this.mode !== "crop" || this.preset.value !== "custom";
this.undoButton.disabled = !!this.busy || !this.editor.history.length;
this.clearButton.disabled = !!this.busy || !this.editor.ready;
this.blendButton.disabled = !this.layer;
this.commitButton.disabled = !this.layer;
this.maskButton.classList.toggle("dd-active", this.target === "mask");
this.blendButton.classList.toggle("dd-active", this.target === "blend");
this.modeSelect.disabled = !!this.busy;
@@ -498,14 +500,17 @@ export class InlineCropController {
if (this.widgets.layer_image.value) {
this.layer = await load(this.imageUrl(this.widgets.layer_image.value));
await this.makeBlend(this.widgets.blend_data.value);
if (this.mode === "crop") this.activate("blend");
// Restore the state the user left: a non-empty blend mask means they were
// still adjusting the blend layer, otherwise the adopted layer is already
// baked and we stay on the repaint mask.
if (this.widgets.blend_data.value && this.mode === "crop") this.activate("blend");
}
}
this.revision++;
this.sync();
this.editor.schedule();
}
async accept(record, {allowBusy = false} = {}) {
async accept(record, {allowBusy = false, image = null} = {}) {
if (
this.editor.destroyed ||
this.mode !== "crop" ||
@@ -520,7 +525,7 @@ export class InlineCropController {
this.resultTimer = setTimeout(() => {
const next = this.pendingResult;
this.pendingResult = null;
this.accept(next).catch((error) =>
this.accept(next, {allowBusy}).catch((error) =>
this.editor.status(error.message, true),
);
}, 50);
@@ -531,7 +536,9 @@ export class InlineCropController {
: record.image.filename;
if (name === this.widgets.layer_image.value) return;
const version = ++this.acceptVersion;
const image = await load(this.imageUrl(name));
// The adoption path already decoded this image once; reuse it instead of
// downloading and decoding the full-size result a second time.
const decoded = image || await load(this.imageUrl(name));
if (
this.editor.destroyed ||
version !== this.acceptVersion ||
@@ -539,11 +546,13 @@ export class InlineCropController {
this.mode !== "crop"
)
return;
this.layer = image;
this.layer = decoded;
this.widgets.layer_image.value = name;
if (!this.contexts.blend && this.target !== "blend")
await this.makeBlend(this.widgets.blend_data.value);
this.activate("blend");
// Adopt then return: with an empty blend mask the backend hard-composites
// the whole crop from layer_image, so the result is immediately baked into
// the source and the repaint mask is reused on the next generation. Blending
// is no longer entered automatically; the ◐ button opens it on demand.
this.activate("mask");
this.revision++;
this.sync();
this.editor.schedule();
+7 -5
View File
@@ -1,10 +1,10 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { InlineMaskEditor } from "./editor.js?v=0.6.7";
import { InlineMaskEditor } from "./editor.js?v=0.8.0";
import { installInpaintWorkbench } from "./inpaint_node.js?v=0.6.1";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { InlineCropController } from "./inline_crop.js?v=0.6.9";
import { InlineCropController } from "./inline_crop.js?v=0.8.1";
import { canStoreDraft, prepareInlineDraft, syncInlineWidgets } from "./draft_guard.js?v=0.6.3";
import { T } from "./i18n.js";
import { removeLegacyFeedback } from "./legacy_feedback.js?v=0.6.4";
@@ -132,7 +132,7 @@ app.registerExtension({
onImage() { if (!restoring || !node.properties.adachi_compact_ui) fitNodeToImage(); },
onGraphWheel: event => app.canvas?.processMouseWheel(event),
async onUpload(file, { preserveMask = false, preserveCrop = false } = {}) {
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the source image to finish loading before adopting a result"));
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the current image to finish loading"));
const version = ++uploadVersion;
const body = new FormData();
body.append("image", file, file.name);
@@ -145,14 +145,16 @@ app.registerExtension({
const name = uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
restoring = false;
crop?.activate("mask");
if (preserveMask && editor.ready) await editor.replaceSource(imageUrl(name), file.name);
let kept = false;
if (preserveMask && editor.ready) kept = await editor.replaceSource(imageUrl(name), file.name);
else await editor.setImage(imageUrl(name), "", file.name);
if (version !== uploadVersion || editor.destroyed) return;
imageWidget.value = name;
maskWidget.value = preserveMask ? editor.flush() : "";
maskWidget.value = kept ? editor.flush() : "";
sourceWidget.value = editor.embed.checked ? editor.sourceData() : "";
if (!preserveCrop) crop?.reset();
await beforeModeChange();
if (preserveMask && !kept) editor.notice(T("The new image has a different size, so the mask was reset"));
markChanged();
},
onEmbed(enabled) {
+5 -2
View File
@@ -40,9 +40,12 @@ export function installResultAdoption(node, crop) {
throw Error(T("The crop changed while adopting the result"));
}
const descriptor = {filename:uploaded.name, subfolder:uploaded.subfolder || "", type:"input"};
await crop.accept({job:region.job, image:descriptor}, {allowBusy:true});
await crop.accept({job:region.job, image:descriptor}, {allowBusy:true, image});
const name = descriptor.subfolder ? `${descriptor.subfolder}/${descriptor.filename}` : descriptor.filename;
if (crop.widgets.layer_image.value !== name) throw Error(T("The crop changed while adopting the result"));
if (crop.widgets.layer_image.value !== name) {
// accept() may have skipped silently (busy / changed job / duplicate).
throw Error(T("The result was not applied; the crop or layer state changed"));
}
editor.status("");
} finally {
URL.revokeObjectURL(objectUrl); crop.busy = false; crop.sync();