Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3030531275 | ||
|
|
f76b5aa0fd | ||
|
|
175a063195 | ||
|
|
da4a26c982 |
@@ -1,5 +1,17 @@
|
||||
# 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.
|
||||
|
||||
+6
-3
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "adachi-inpaint-workbench"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
description = "Inline mask painting and WebUI-style inpainting nodes for ComfyUI"
|
||||
requires-python = ">=3.10"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
+3
-1
@@ -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
@@ -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";
|
||||
|
||||
+8
-4
@@ -227,12 +227,16 @@ export class InlineMaskEditor {
|
||||
status(message, error = false) {
|
||||
this.message.textContent = message;
|
||||
this.message.classList.toggle("dd-error", error);
|
||||
// Errors and deliberate notices stay visible; transient progress lines hide.
|
||||
this.message.hidden = !message || (!error && !this.notice);
|
||||
this.notice = false;
|
||||
// Only errors stay visible; transient hints and progress lines hide.
|
||||
this.message.hidden = !error;
|
||||
}
|
||||
|
||||
notice(message) { this.notice = true; this.status(message); }
|
||||
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; }
|
||||
|
||||
@@ -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": "编辑模式",
|
||||
|
||||
+30
-21
@@ -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();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { InlineMaskEditor } from "./editor.js?v=0.7.1";
|
||||
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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user