feat(inpaint): Blend mode is now a node setting; fix paste-into-LoadImage source load
- blend_mode (mask / whole crop) is a Crop node combo widget, placed above invert_mask, mirrored by the editor's Blend mode pill (writeBackWidgets / readParams; _BLEND_MODE maps label <-> key). run() sets crop_info["blend_mode"] from the widget (the source of truth, not state_json). Tooltip + toolbar help + docs now explain Mask (replace only the painted area) vs Whole crop (replace the entire crop). invert_mask stays the last setting, under softness. - getUpstreamImageURL now parses a LoadImage value's subfolder + [input]/[output]/ [temp] annotation, so pasting an image into a Load Image node then opening the mask editor loads it instead of "Failed to load the source image" (only paste hit this, since it lands in a subfolder). - the node-body paste handler steps aside when the editor is open. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
29f336bb5b
commit
11afe8369a
@@ -860,7 +860,7 @@ Inpaint Crop Pixaroma (`nodes/node_inpaint_crop.py`) + Inpaint Stitch Pixaroma (
|
||||
|
||||
4. **Mask resizes ALWAYS use NEAREST, never bilinear** (apply_inpaint_crop out-mask + stitch_back alpha). Bilinear adds its OWN gradient halo on top of the feather (a SECOND softening). The brush is CRISP (the Soft-edge slider was removed in v2; `softness=0`), so `softm = np.maximum(raw, proc)` is just the filled+grown binary core; conditioning softness comes only from `mask_blur`. The conditioning out-mask gets the deliberate `mask_blur` gaussian; the stitch seam gets the deliberate `blend` feather - one intentional softening each, never two.
|
||||
|
||||
5. **Seam settings are SPLIT three ways (v2 + user feedback): `softness` = Crop node widget (mirrored in the editor), `blend_mode` = editor-only state, `color_match` = Stitch node widget.** `softness` (0-150, the seam feather = stitch `blend`) is a native Crop INT widget AND the editor's Softness slider, mirrored like the geometry knobs (`node_inpaint_crop.py::run` feeds it into the crop geometry as `params["blend"]` so the crop CONTEXT grows to fit the feather - `compute_region` uses `max(context_px, blend)`, Option B / Pattern 3 - AND injects it into `crop_info["blend"]`; the editor's `writeBackWidgets` writes the slider back to the `softness` widget on Save; `readParams` reads it). `blend_mode` (mask/whole_crop) is editor-only `state_json` -> `crop_info["blend_mode"]`. `color_match` (off/subtle/strong) is the Inpaint STITCH node's OWN widget - a post-result tweak with no live preview (it was briefly in the editor in early v2, moved back per user feedback); it is NOT in `crop_info`. The crop knobs (`size_mode/target/multiple/context_px/mask_grow/mask_blur/softness`) are all native Crop widgets AND mirrored into the editor; on Save the editor writes ALL back via `writeBackWidgets`. The Stitch node reads `blend`+`blend_mode` from `crop_info` (defaults `16`/`mask` when absent - e.g. an Image Crop `crop_info`) and `color_match` from its own widget. The brush `Soft edge` slider was removed - the brush is crisp; the editor's `Softness` slider owns seam softness with a live (approximate) DPR-backed preview tinted in a switchable color (`Pixaroma.Inpaint.PreviewColor` setting, display-only). `color_match` matches the inpaint over the UNMASKED CONTEXT (surroundings outside the mask), NOT the mask or the whole crop (both include the masked area and drag a deliberately-changed color back - red->white dress goes pink); it corrects only lighting/tone drift in the unchanged surroundings (falls back to uniform if the mask fills the crop). It is for blending an inpaint INTO the scene, not for deliberate recolors. Seam preview math now mirrors `_blur_alpha`'s SCIPY smoothstep (`render.mjs::_seamAlphaCanvas` via `geometry.mjs::seamAlphaFromAlpha`, a downscaled `(1,√2)` chamfer DT) so preview == result; see `docs/inpaint-crop-math.md` §4.
|
||||
5. **Seam settings are SPLIT three ways (v2 + user feedback): `softness` = Crop node widget (mirrored in the editor), `blend_mode` = Crop node combo widget too (mirrored in the editor, 2026-06-16), `color_match` = Stitch node widget.** `softness` (0-150, the seam feather = stitch `blend`) is a native Crop INT widget AND the editor's Softness slider, mirrored like the geometry knobs (`node_inpaint_crop.py::run` feeds it into the crop geometry as `params["blend"]` so the crop CONTEXT grows to fit the feather - `compute_region` uses `max(context_px, blend)`, Option B / Pattern 3 - AND injects it into `crop_info["blend"]`; the editor's `writeBackWidgets` writes the slider back to the `softness` widget on Save; `readParams` reads it). `blend_mode` (mask/whole_crop) is now ALSO a native Crop combo widget (placed just above `invert_mask`), mirrored by the editor's Blend mode pill via `writeBackWidgets`/`readParams` (`_BLEND_MODE` maps the friendly label `mask`/`whole crop` <-> the internal key); `run` sets `crop_info["blend_mode"]` from the WIDGET, not `state_json` (the widget is the source of truth). `color_match` (off/subtle/strong) is the Inpaint STITCH node's OWN widget - a post-result tweak with no live preview (it was briefly in the editor in early v2, moved back per user feedback); it is NOT in `crop_info`. The crop knobs (`size_mode/target/multiple/context_px/mask_grow/mask_blur/softness`) are all native Crop widgets AND mirrored into the editor; on Save the editor writes ALL back via `writeBackWidgets`. The Stitch node reads `blend`+`blend_mode` from `crop_info` (defaults `16`/`mask` when absent - e.g. an Image Crop `crop_info`) and `color_match` from its own widget. The brush `Soft edge` slider was removed - the brush is crisp; the editor's `Softness` slider owns seam softness with a live (approximate) DPR-backed preview tinted in a switchable color (`Pixaroma.Inpaint.PreviewColor` setting, display-only). `color_match` matches the inpaint over the UNMASKED CONTEXT (surroundings outside the mask), NOT the mask or the whole crop (both include the masked area and drag a deliberately-changed color back - red->white dress goes pink); it corrects only lighting/tone drift in the unchanged surroundings (falls back to uniform if the mask fills the crop). It is for blending an inpaint INTO the scene, not for deliberate recolors. Seam preview math now mirrors `_blur_alpha`'s SCIPY smoothstep (`render.mjs::_seamAlphaCanvas` via `geometry.mjs::seamAlphaFromAlpha`, a downscaled `(1,√2)` chamfer DT) so preview == result; see `docs/inpaint-crop-math.md` §4.
|
||||
|
||||
6. **Editor brush: per-stroke buffer + `lighten` composite + blur-on-bake.** Stamps are crisp white discs drawn on `_sctx` (the per-stroke buffer) with `globalCompositeOperation="lighten"` (max-blend → overlapping stamps can't stack into a hard/dark rim regardless of stroke speed); at stroke end `_compositeStroke` bakes the buffer onto the mask with a gaussian of width `softness*radius`. **v2: the Soft-edge slider was removed and `softness` is fixed at 0, so blur-on-bake is off and the brush is CRISP** (the stamps' anti-aliased discs still prevent jaggies); seam softness is the `Softness`/`blend` slider instead. The mask canvas is white-RGB + per-pixel-alpha (export composites over black → grayscale; the brush soft edge survives). `_exportMaskDataURL` uses `_effectiveMaskCanvas()` so a Ctrl+S mid-drag saves the in-progress stroke; undo/redo mid-drag call `_abandonStroke()` so the abandoned stroke isn't re-baked on mouseup.
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ A feather wider than the context margin used to leave a nonzero alpha at the cro
|
||||
|
||||
## 5. The settings flow
|
||||
|
||||
`softness` (the seam feather = `blend`) is a Crop node INT widget (0-150), mirrored by the editor's Softness slider; `node_inpaint_crop.py::run` feeds it into the crop geometry as `params["blend"]` (so the crop context grows to fit the feather - section 1) AND injects it into `crop_info["blend"]` (clamped 0-150) for the stitch. `blend_mode` is editor-only `state_json` -> `crop_info["blend_mode"]`. `node_inpaint_stitch.py` reads `blend` + `blend_mode` from `crop_info` (defaults `16` / `mask` for an Image Crop `crop_info` that lacks them).
|
||||
`softness` (the seam feather = `blend`) is a Crop node INT widget (0-150), mirrored by the editor's Softness slider; `node_inpaint_crop.py::run` feeds it into the crop geometry as `params["blend"]` (so the crop context grows to fit the feather - section 1) AND injects it into `crop_info["blend"]` (clamped 0-150) for the stitch. `blend_mode` (`mask` / `whole crop`) is ALSO a Crop node combo widget now (mirrored by the editor's Blend mode pill, same as the other seam knobs - the node widget is the source of truth; `_BLEND_MODE` maps the friendly label to the internal key). `run` sets `crop_info["blend_mode"]` from the widget (NOT `state_json`). `mask` = only the painted area is replaced; `whole crop` = the entire crop rectangle is replaced (the `_feather_alpha` rectangle fade in `stitch_back`). `node_inpaint_stitch.py` reads `blend` + `blend_mode` from `crop_info` (defaults `16` / `mask` for an Image Crop `crop_info` that lacks them).
|
||||
|
||||
`color_match` (off/subtle/strong) is the STITCH node's OWN widget, not in `crop_info` (a post-result tweak with no live preview). It shifts the inpainted crop's color stats toward the original over the UNMASKED CONTEXT (the surroundings OUTSIDE the mask) — subtle = match mean, strong = match mean + std. NOT the mask and NOT the whole crop: both include the masked area, so they drag the inpaint's DELIBERATELY changed colors back toward the original (a red->white dress goes pink). Matching the context corrects only the lighting/tone drift in the unchanged surroundings. Falls back to uniform if the mask ~fills the crop (no context). So it is for blending an inpaint INTO the scene, not for deliberate recolors (keep it off for those).
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ const HELP = {
|
||||
bullets: [
|
||||
"Wire an image in, or drag-drop / paste one onto the node body.",
|
||||
"Click `Open mask editor` and paint over the area to change. Use Brush / Erase, the size slider (or `[` `]` and the mouse wheel), Clear and Invert.",
|
||||
"In the editor, drag `Softness` to set how soft the seam blends - the preview shows it live (or set `softness` right on the node). Mask grow, blend mode, crop size and context live in the editor too.",
|
||||
"In the editor, drag `Softness` to set how soft the seam blends - the preview shows it live. Softness, blend mode, mask grow, crop size and context are on the node AND mirrored in the editor, so set them wherever is handier.",
|
||||
"Click `Save`, then wire `image` and `mask` into your inpaint model.",
|
||||
"Send `crop_info` to Inpaint Stitch Pixaroma.",
|
||||
],
|
||||
@@ -189,6 +189,7 @@ const HELP = {
|
||||
heading: "Settings",
|
||||
defs: [
|
||||
["softness", "How far the seam feathers when the crop is pasted back. Higher values grow the crop a little so the soft blend has room (you'll see the crop box expand). The editor previews it live; it travels to Inpaint Stitch automatically."],
|
||||
["blend mode", "How the result is pasted back. Mask: only the area you painted is replaced, the rest of the crop keeps the original (the normal inpaint). Whole crop: the ENTIRE cropped box is replaced with the model's version - use when the model also relit or changed the surroundings, or for an img2img-style pass."],
|
||||
["size mode", "Keep shape scales the long side to the target with no stretching (best quality). Force size outputs a square. Free keeps the natural size."],
|
||||
["context px", "How much surrounding area to include around your mask."],
|
||||
["mask grow / blur", "Expand the mask before cropping, and soften the mask the model sees."],
|
||||
@@ -214,14 +215,14 @@ const HELP = {
|
||||
sections: [
|
||||
{
|
||||
heading: "What it does",
|
||||
body: "The other half of the inpaint workflow. After Inpaint Crop Pixaroma cropped a region and you ran your model on it, this node resizes the result back and blends it into the original at the exact spot. By default only the painted area changes, so everything else stays pixel-perfect.\n\nThe seam blend and blend mode are set in the Inpaint Crop Pixaroma editor and ride the `crop_info` wire. The one knob here is `color match`, which corrects a color or tone shift the model introduced by matching the unchanged surroundings around your mask. Keep it Off when you deliberately changed colors (it would pull them back); use it when the inpaint should blend into the scene. Set it and re-run after seeing the result. It also hands back the untouched `original` so you can compare before and after.",
|
||||
body: "The other half of the inpaint workflow. After Inpaint Crop Pixaroma cropped a region and you ran your model on it, this node resizes the result back and blends it into the original at the exact spot. By default only the painted area changes, so everything else stays pixel-perfect.\n\nThe seam softness and blend mode are set on the Inpaint Crop Pixaroma node (or its editor) and ride the `crop_info` wire. The one knob here is `color match`, which corrects a color or tone shift the model introduced by matching the unchanged surroundings around your mask. Keep it Off when you deliberately changed colors (it would pull them back); use it when the inpaint should blend into the scene. Set it and re-run after seeing the result. It also hands back the untouched `original` so you can compare before and after.",
|
||||
},
|
||||
{
|
||||
heading: "How to use",
|
||||
bullets: [
|
||||
"Wire the `crop_info` output of Inpaint Crop Pixaroma into `crop_info`.",
|
||||
"Wire your inpainted crop (after the model) into `image`. It is resized back automatically.",
|
||||
"Set the seam softness and blend mode in the Inpaint Crop editor; set `color match` here if the inpaint came out slightly off-color.",
|
||||
"Set the seam softness and blend mode on the Inpaint Crop node (or its editor); set `color match` here if the inpaint came out slightly off-color.",
|
||||
"Run the workflow to get the finished full image.",
|
||||
"Wire `image` and `original` into Image Compare Pixaroma for an instant before / after.",
|
||||
],
|
||||
|
||||
+33
-12
@@ -18,6 +18,9 @@ const SIZE_MODE_MAP = {
|
||||
"free (multiple only)": "free",
|
||||
};
|
||||
|
||||
// node-widget label <-> internal blend_mode key (the editor pill uses the internal key)
|
||||
const BLEND_MODE_MAP = { "mask": "mask", "whole crop": "whole_crop" };
|
||||
|
||||
function readParams(node) {
|
||||
const g = (n) => node.widgets?.find((w) => w.name === n)?.value;
|
||||
return {
|
||||
@@ -28,22 +31,16 @@ function readParams(node) {
|
||||
mask_grow: g("mask_grow") != null ? parseInt(g("mask_grow")) : 4,
|
||||
mask_blur: g("mask_blur") != null ? parseInt(g("mask_blur")) : 4,
|
||||
blend: g("softness") != null ? parseInt(g("softness")) : 16,
|
||||
// blend_mode is now a node widget too (mirrored by the editor pill).
|
||||
blend_mode: BLEND_MODE_MAP[g("blend_mode")] || "mask",
|
||||
};
|
||||
}
|
||||
|
||||
// blend settings live in the editor state_json (not node widgets); read them so
|
||||
// the editor restores them on open.
|
||||
function readSeam(jsonStr) {
|
||||
// blend_mode is editor-only state (softness rides the node 'softness' widget,
|
||||
// color_match is the Stitch node's own knob).
|
||||
let meta = {};
|
||||
try { meta = JSON.parse(jsonStr || "{}") || {}; } catch {}
|
||||
return { blend_mode: meta.blend_mode || "mask" };
|
||||
}
|
||||
|
||||
// friendly-label <- internal size-mode key, for writing the editor's choice back
|
||||
const SIZE_MODE_LABEL = Object.fromEntries(
|
||||
Object.entries(SIZE_MODE_MAP).map(([k, v]) => [v, k]));
|
||||
const BLEND_MODE_LABEL = Object.fromEntries(
|
||||
Object.entries(BLEND_MODE_MAP).map(([k, v]) => [v, k]));
|
||||
|
||||
function setNodeWidget(node, name, value) {
|
||||
const w = node.widgets?.find((x) => x.name === name);
|
||||
@@ -61,6 +58,8 @@ function writeBackWidgets(node, extra) {
|
||||
if (extra.multiple != null) setNodeWidget(node, "multiple", extra.multiple);
|
||||
if (extra.size_mode != null)
|
||||
setNodeWidget(node, "size_mode", SIZE_MODE_LABEL[extra.size_mode] || "keep shape (long side)");
|
||||
if (extra.blend_mode != null)
|
||||
setNodeWidget(node, "blend_mode", BLEND_MODE_LABEL[extra.blend_mode] || "mask");
|
||||
}
|
||||
|
||||
function buildSourceURL(part, bust) {
|
||||
@@ -71,6 +70,26 @@ function buildSourceURL(part, bust) {
|
||||
return bust ? `${url}&t=${Date.now()}` : url;
|
||||
}
|
||||
|
||||
// A LoadImage combo value can be "name.png", "sub/name.png", or carry an
|
||||
// annotation like "clipspace/clipboard.png [input]" (this is what PASTING into a
|
||||
// LoadImage produces - a subfolder + suffix). /view wants filename + subfolder as
|
||||
// SEPARATE params and no annotation, so split it; otherwise the editor 404s with
|
||||
// "Failed to load the source image" (only on paste, since a plain pick has no
|
||||
// subfolder/suffix). Mirrors the image-picker split used elsewhere.
|
||||
function parseAnnotatedImageValue(value) {
|
||||
let v = String(value || "");
|
||||
let type = "input";
|
||||
const m = v.match(/\s*\[(input|output|temp)\]\s*$/i);
|
||||
if (m) { type = m[1].toLowerCase(); v = v.slice(0, m.index); }
|
||||
v = v.replace(/\\/g, "/").trim();
|
||||
const i = v.lastIndexOf("/");
|
||||
return {
|
||||
filename: i >= 0 ? v.slice(i + 1) : v,
|
||||
subfolder: i >= 0 ? v.slice(0, i) : "",
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
function getUpstreamImageURL(node) {
|
||||
// Prefer the LIVE wired source so a just-changed Load Image (or any live
|
||||
// preview) is what the editor opens. The cached executed-source URL below is
|
||||
@@ -86,7 +105,7 @@ function getUpstreamImageURL(node) {
|
||||
if (src) {
|
||||
if (src.comfyClass === "LoadImage" || src.type === "LoadImage") {
|
||||
const w = (src.widgets || []).find((x) => x.name === "image");
|
||||
if (w && w.value) return `/view?filename=${encodeURIComponent(w.value)}&type=input&t=${Date.now()}`;
|
||||
if (w && w.value) return buildSourceURL(parseAnnotatedImageValue(w.value), true);
|
||||
}
|
||||
if (src.imgs && src.imgs.length > 0) {
|
||||
const img = src.imgs[link.origin_slot] || src.imgs[0];
|
||||
@@ -111,6 +130,8 @@ function installPasteHandler() {
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||
const node = findActiveNode();
|
||||
if (!node) return;
|
||||
// editor open -> let the editor's own paste handler load it into the canvas
|
||||
if (node._pixInpaintEditor?.el?.overlay?.isConnected) return;
|
||||
const items = e.clipboardData?.items || [];
|
||||
const it = Array.from(items).find((x) => x.type?.startsWith("image/"));
|
||||
if (!it) return;
|
||||
@@ -244,7 +265,7 @@ app.registerExtension({
|
||||
editor.onClose = () => { captureBrush(); node._pixInpaintEditor = null; node.setDirtyCanvas(true, true); };
|
||||
|
||||
editor.open(stateJson, getUpstreamImageURL(node),
|
||||
{ ...readParams(node), ...readSeam(stateJson) }, node._pixInpaintBrush);
|
||||
readParams(node), node._pixInpaintBrush);
|
||||
});
|
||||
|
||||
// ── mini-preview DOM widget (also carries the hidden state) ──
|
||||
|
||||
@@ -265,6 +265,7 @@ proto._save = async function () {
|
||||
size_mode: this.params.size_mode,
|
||||
target: this.params.target,
|
||||
multiple: this.params.multiple,
|
||||
blend_mode: this.params.blend_mode, // mirror the editor pill back to the node widget
|
||||
};
|
||||
const preview = this._buildPreview();
|
||||
if (this.onSave) this.onSave(JSON.stringify(state), extra, preview);
|
||||
|
||||
@@ -18,6 +18,12 @@ _SIZE_MODE = {
|
||||
"free (multiple only)": "free",
|
||||
}
|
||||
|
||||
# Seam paste-back mode -> internal key. Mirrored by the editor's Blend mode pill.
|
||||
_BLEND_MODE = {
|
||||
"mask": "mask",
|
||||
"whole crop": "whole_crop",
|
||||
}
|
||||
|
||||
|
||||
class _InpaintOptionalInputs(FlexibleOptionalInputType):
|
||||
"""Declares concrete optional IMAGE + MASK inputs (so drag-from-output search
|
||||
@@ -107,6 +113,18 @@ class PixaromaInpaintCrop:
|
||||
"crop back. Previewed live in the mask editor."
|
||||
),
|
||||
}),
|
||||
"blend_mode": (list(_BLEND_MODE.keys()), {
|
||||
"default": "mask",
|
||||
"tooltip": (
|
||||
"How Inpaint Stitch pastes the result back. 'mask': only the "
|
||||
"area you painted is replaced - the rest of the crop keeps the "
|
||||
"original (the normal inpaint, the safe default). 'whole crop': "
|
||||
"the entire cropped region is replaced with the model's version "
|
||||
"(use when the model also relit / changed the surroundings, or "
|
||||
"for an img2img-style pass over the whole crop). Previewed live "
|
||||
"in the mask editor."
|
||||
),
|
||||
}),
|
||||
"invert_mask": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
@@ -142,7 +160,7 @@ class PixaromaInpaintCrop:
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Re-run on any knob change, or when the painted-mask file changes."""
|
||||
parts = [str(kwargs.get(k)) for k in
|
||||
("size_mode", "target", "multiple", "context_px", "mask_grow", "mask_blur", "softness", "invert_mask")]
|
||||
("size_mode", "target", "multiple", "context_px", "mask_grow", "mask_blur", "softness", "blend_mode", "invert_mask")]
|
||||
state = kwargs.get("InpaintCropWidget")
|
||||
try:
|
||||
sj = state.get("state_json", "{}") if isinstance(state, dict) else str(state)
|
||||
@@ -229,7 +247,8 @@ class PixaromaInpaintCrop:
|
||||
return merge_params(p)
|
||||
|
||||
def run(self, size_mode="keep shape (long side)", target=1024, multiple=8,
|
||||
context_px=24, mask_grow=4, mask_blur=4, softness=16, invert_mask=False, **kwargs):
|
||||
context_px=24, mask_grow=4, mask_blur=4, softness=16, blend_mode="mask",
|
||||
invert_mask=False, **kwargs):
|
||||
upstream = kwargs.get("image")
|
||||
upstream_mask = kwargs.get("mask")
|
||||
state = kwargs.get("InpaintCropWidget")
|
||||
@@ -279,11 +298,11 @@ class PixaromaInpaintCrop:
|
||||
print(f"[PixaromaInpaintCrop] crop error: {e}")
|
||||
return self._empty()
|
||||
|
||||
# blend mode (editor-only) also rides crop_info. (color_match is now the
|
||||
# Stitch node's own knob.)
|
||||
# blend mode rides crop_info. It is now a NODE widget (mirrored by the editor's
|
||||
# Blend mode pill), so the node widget is the source of truth - not state_json.
|
||||
# (color_match is the Stitch node's own knob.)
|
||||
crop_info["blend"] = sb
|
||||
_bm = str(meta.get("blend_mode", "mask"))
|
||||
crop_info["blend_mode"] = _bm if _bm in ("mask", "whole_crop") else "mask"
|
||||
crop_info["blend_mode"] = _BLEND_MODE.get(blend_mode, "mask")
|
||||
|
||||
result = (img_t, mask_t, crop_info, ow, oh)
|
||||
if ui_payload:
|
||||
|
||||
Reference in New Issue
Block a user