From dfcea9d7571ec182ab8a2ddc39b8a8c967efb76d Mon Sep 17 00:00:00 2001 From: Mehdi Chaid Date: Wed, 21 Oct 2026 13:18:51 -0400 Subject: [PATCH] feat: Flake Inpaint chain node feeding Flake Generate (#428) --- README.md | 16 +++++++ flake_data_nodes.py | 15 ++++-- flake_extra_nodes.py | 93 +++++++++++++++++++++--------------- full_flake_node.py | 10 ++-- tests/test_hook_schedules.py | 2 +- tests/test_model_patch_cn.py | 2 +- tests/test_preset_loader.py | 2 +- 7 files changed, 88 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index ee26f33..72c550b 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,22 @@ at full size and scrolls both ways. Landscape outputs widen the overlay to nearly the screen width. Prompts, LoRAs and model details are folded under the `^` at the bottom of each panel. +### Flake Inpaint + +Insert it right before Flake Generate to redraw part of an image with the +chain's model, LoRAs and prompt. Load the image on the node and paint the area +to redraw with ComfyUI's mask editor (right-click → Open in MaskEditor). The +image's size replaces the chain resolution. + +- **denoise** `1.0` fills the masked area from scratch (grown by `grow_mask`); + below `1.0` it keeps the original as a starting point. Flake Generate samples + at this denoise. +- Without a mask the whole image is redrawn at `denoise` (img2img). +- `positive_prompt` / `negative_prompt` are appended to the chain prompt. + +To fix an image Flakes made, drop it into ComfyUI to restore its workflow, +insert Flake Inpaint before Flake Generate and load the same image. + ### Flake Data Split TODO diff --git a/flake_data_nodes.py b/flake_data_nodes.py index 21bcd7c..a4e2963 100644 --- a/flake_data_nodes.py +++ b/flake_data_nodes.py @@ -86,7 +86,7 @@ def _split_flake_data(flake_data): ) else: filename_prefix = "" - steps, cfg, sampler_name, scheduler = sampling_preset + steps, cfg, sampler_name, scheduler, denoise = sampling_preset return { "model": model, "clip": clip, @@ -101,6 +101,7 @@ def _split_flake_data(flake_data): "cfg": cfg, "sampler_name": sampler_name, "scheduler": scheduler, + "denoise": denoise, } @@ -196,7 +197,7 @@ class IntoFlakeDataAll: else: generation_data = None - sampling_preset = (steps, cfg, sampler_name, scheduler) + sampling_preset = (steps, cfg, sampler_name, scheduler, 1.0) return ((model_bundle, generation_data, sampling_preset),) @@ -246,7 +247,7 @@ class IntoFlakeDataSelect: orig_filename_state = {} if len(orig_generation_data) > 7 and isinstance(orig_generation_data[7], dict): orig_filename_state = orig_generation_data[7] - orig_steps, orig_cfg, orig_sampler, orig_scheduler = orig_sampling_preset + orig_steps, orig_cfg, orig_sampler, orig_scheduler, orig_denoise = orig_sampling_preset new_model_bundle = ( model if model is not None else orig_model, @@ -272,6 +273,7 @@ class IntoFlakeDataSelect: cfg if cfg is not None else orig_cfg, sampler_name if sampler_name is not None else orig_sampler, scheduler if scheduler is not None else orig_scheduler, + orig_denoise, ) return ((new_model_bundle, new_generation_data, new_sampling_preset),) @@ -349,11 +351,12 @@ class PreviewFlakeData: # --- Parameters --- if sampling_preset is not None: - steps, cfg, sampler_name, scheduler = sampling_preset + steps, cfg, sampler_name, scheduler, denoise = sampling_preset params_info["Steps"] = str(steps) params_info["CFG"] = str(cfg) params_info["Sampler"] = str(sampler_name) params_info["Scheduler"] = str(scheduler) + params_info["Denoise"] = str(denoise) # --- Metadata --- if generation_data is not None: @@ -495,6 +498,7 @@ class FlakeGenerate: cfg = parts["cfg"] sampler_name = parts["sampler_name"] scheduler = parts["scheduler"] + denoise = parts["denoise"] # Distilled models sample at cfg 1 with a zeroed negative (#359). Done # here rather than at preset-load time because this is the one place the @@ -513,7 +517,7 @@ class FlakeGenerate: ks = KSampler() sampled = ks.sample( model, seed, steps, cfg, sampler_name, scheduler, - positive, negative, latent, denoise=1.0, + positive, negative, latent, denoise=denoise, ) sampled_latent = sampled[0] @@ -630,6 +634,7 @@ class FlakeGenerate: models_info["CFG"] = str(cfg) models_info["Sampler"] = str(sampler_name) models_info["Scheduler"] = str(scheduler) + models_info["Denoise"] = str(denoise) # ── Inputs overlay: prompts (grouped by BREAK) + controlnet info ── inputs_info = {} diff --git a/flake_extra_nodes.py b/flake_extra_nodes.py index cb2d244..fe4c79e 100644 --- a/flake_extra_nodes.py +++ b/flake_extra_nodes.py @@ -1,11 +1,16 @@ """Extra Flake nodes that wrap optional, soft-dependency features: -IPAdapter FaceID (#289) and Inpaint (#291). +IPAdapter FaceID (#289) and Inpaint (#291, #428). These operate on the FLAKE_DATA bundle (model_bundle, generation_data, sampling_preset) produced by FlakeModelPreset / FlakeStack. """ from __future__ import annotations +from nodes import CLIPTextEncode, LoadImage, SetLatentNoiseMask, VAEEncode, VAEEncodeForInpaint + +from .flake_compose import carry_schedules +from .full_flake_node import _carry_controlnets + class FlakeIPAdapter: """Apply IPAdapter FaceID to the model inside a FLAKE_DATA bundle for @@ -45,17 +50,20 @@ class FlakeIPAdapter: return (((new_model, clip, vae), generation_data, sampling_preset),) +def _reencode(clip, cond, text): + """Encode ``text`` and carry the hooks, scheduled prompts and ControlNets + of ``cond`` onto it, like FlakeStack does.""" + new_cond = CLIPTextEncode().encode(clip, text)[0] + return _carry_controlnets(cond, carry_schedules(cond, new_cond, [], [])) + + class FlakeInpaint: - """Inpaint a masked region of an image using a FLAKE_DATA bundle's model, - conditioning, and sampler settings (#291). + """Put an image into a FLAKE_DATA bundle so Flake Generate inpaints it + (#291, #428). - Takes an input image + mask (wire a Load Image / mask source) and the - generation parameters from FLAKE_DATA. Optional prompt overrides re-encode - using the bundle's CLIP. Outputs the inpainted IMAGE. - - NOTE: the draft's on-node image preview with Mask/Inputs/Remove hover - buttons (canvas mask editor) is a frontend follow-up; this node provides the - working inpaint backend via input pins in the meantime. + The mask is the image's alpha channel, painted with ComfyUI's mask editor + on the node. Without a mask the whole image is redrawn at ``denoise``. + Optional prompts are appended to the chain prompt. """ @classmethod @@ -63,48 +71,55 @@ class FlakeInpaint: return { "required": { "flake_data": ("FLAKE_DATA",), - "image": ("IMAGE",), - "mask": ("MASK",), - "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}), + "image": LoadImage.INPUT_TYPES()["required"]["image"], + "denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "grow_mask": ("INT", {"default": 6, "min": 0, "max": 64, + "tooltip": "Grow the mask by this many pixels. Used at denoise 1."}), }, "optional": { - "denoise": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.05}), - "grow_mask": ("INT", {"default": 6, "min": 0, "max": 256}), "positive_prompt": ("STRING", {"default": "", "multiline": True}), "negative_prompt": ("STRING", {"default": "", "multiline": True}), }, } - RETURN_TYPES = ("IMAGE",) - RETURN_NAMES = ("image",) + RETURN_TYPES = ("FLAKE_DATA",) + RETURN_NAMES = ("flake_data",) FUNCTION = "execute" CATEGORY = "flakes" DESCRIPTION = ( - "Inpaint a masked region using the model, conditioning, and sampler " - "settings from a FLAKE_DATA bundle. Optional prompt overrides." + "Inpaint the masked area of an image: insert before Flake Generate. Paint " + "the mask with the mask editor; without one the whole image is redrawn at denoise." ) - def execute(self, flake_data, image, mask, seed, denoise=0.7, grow_mask=6, + def execute(self, flake_data, image, denoise=1.0, grow_mask=6, positive_prompt="", negative_prompt=""): - from nodes import VAEEncodeForInpaint, KSampler, VAEDecode, CLIPTextEncode - model_bundle, generation_data, sampling_preset = flake_data - model, clip, vae = model_bundle - positive, negative = generation_data[0], generation_data[1] - steps, cfg, sampler_name, scheduler = sampling_preset + _, clip, vae = model_bundle + positive, negative, _, _, _, pos_text, neg_text = generation_data[:7] - # Optional prompt overrides — re-encode with the bundle's CLIP. - if positive_prompt.strip() or negative_prompt.strip(): - encoder = CLIPTextEncode() - if positive_prompt.strip(): - positive = encoder.encode(clip, positive_prompt)[0] - if negative_prompt.strip(): - negative = encoder.encode(clip, negative_prompt)[0] + pixels, mask = LoadImage().load_image(image) + if not mask.any(): + latent = VAEEncode().encode(vae, pixels)[0] + elif denoise < 1.0: + latent = SetLatentNoiseMask().set_mask(VAEEncode().encode(vae, pixels)[0], mask)[0] + else: + latent = VAEEncodeForInpaint().encode(vae, pixels, mask, grow_mask_by=grow_mask)[0] - latent = VAEEncodeForInpaint().encode(vae, image, mask, grow_mask_by=grow_mask)[0] - sampled = KSampler().sample( - model, seed, steps, cfg, sampler_name, scheduler, - positive, negative, latent, denoise=denoise, - )[0] - out_image = VAEDecode().decode(vae, sampled)[0] - return (out_image,) + if positive_prompt.strip(): + pos_text = " BREAK ".join(t for t in (pos_text.strip(), positive_prompt.strip()) if t) + positive = _reencode(clip, positive, pos_text) + if negative_prompt.strip(): + neg_text = ", ".join(t for t in (neg_text.strip(), negative_prompt.strip()) if t) + negative = _reencode(clip, negative, neg_text) + + generation_data = (positive, negative, latent, pixels.shape[2], pixels.shape[1], + pos_text, neg_text) + tuple(generation_data[7:]) + return ((model_bundle, generation_data, sampling_preset[:4] + (denoise,)),) + + @classmethod + def IS_CHANGED(cls, image, **kwargs): + return LoadImage.IS_CHANGED(image) + + @classmethod + def VALIDATE_INPUTS(cls, image): + return LoadImage.VALIDATE_INPUTS(image) diff --git a/full_flake_node.py b/full_flake_node.py index 3021d42..5a44e48 100644 --- a/full_flake_node.py +++ b/full_flake_node.py @@ -306,7 +306,7 @@ def _load_preset_bundle(preset_name: str, model_family: str | None = None): "model_family": model_family, } generation_data = (positive, negative, latent, width, height, pos_text, neg_text, filename_state) - sampling_preset = (preset_data.steps, preset_data.cfg, preset_data.sampler, preset_data.scheduler) + sampling_preset = (preset_data.steps, preset_data.cfg, preset_data.sampler, preset_data.scheduler, 1.0) return model_bundle, generation_data, sampling_preset @@ -327,7 +327,7 @@ def _apply_preset_overrides(overrides_json, model_bundle, generation_data, sampl if not isinstance(ovr, dict) or not ovr: return model_bundle, generation_data, sampling_preset - steps, cfg, sampler, scheduler = sampling_preset + steps, cfg, sampler, scheduler, denoise = sampling_preset def _conv(key, conv, cur): v = ovr.get(key) @@ -344,7 +344,7 @@ def _apply_preset_overrides(overrides_json, model_bundle, generation_data, sampl sampler = str(ovr["sampler"]) if ovr.get("scheduler"): scheduler = str(ovr["scheduler"]) - sampling_preset = (steps, cfg, sampler, scheduler) + sampling_preset = (steps, cfg, sampler, scheduler, denoise) fp = (ovr.get("filename_prefix") or "").strip() if fp and isinstance(generation_data, tuple) and len(generation_data) > 7 and isinstance(generation_data[7], dict): @@ -453,7 +453,7 @@ class FlakeStack: model_bundle, generation_data, sampling_preset = flake_data model, clip, vae = model_bundle positive_cond, negative_cond, latent, width, height, pos_text, neg_text = generation_data[:7] - steps, cfg, sampler, scheduler = sampling_preset + steps, cfg, sampler, scheduler, denoise = sampling_preset # --- Filename prefix state ---------------------------------------------- # Copy the incoming filename_state so we don't mutate the original tuple. @@ -653,7 +653,7 @@ class FlakeStack: out_model_bundle = (model, clip, vae) out_generation_data = (positive_cond, negative_cond, latent, new_width, new_height, new_pos_text, new_neg_text, filename_state) - out_sampling_preset = (steps, cfg, sampler, scheduler) + out_sampling_preset = (steps, cfg, sampler, scheduler, denoise) return ( (out_model_bundle, out_generation_data, out_sampling_preset), diff --git a/tests/test_hook_schedules.py b/tests/test_hook_schedules.py index 60537ac..04d03ee 100644 --- a/tests/test_hook_schedules.py +++ b/tests/test_hook_schedules.py @@ -181,7 +181,7 @@ HALF = [KF(0.0, 1.0), KF(0.5, 0.0)] BASE_DATA = ( ("MODEL", "CLIP", "VAE"), ([["P", {}]], [["N", {}]], {"samples": "l"}, 1024, 1024, "", "", {"stems": []}), - (30, 4.0, "euler", "simple"), + (30, 4.0, "euler", "simple", 1.0), ) diff --git a/tests/test_model_patch_cn.py b/tests/test_model_patch_cn.py index 9707189..98ff334 100644 --- a/tests/test_model_patch_cn.py +++ b/tests/test_model_patch_cn.py @@ -205,7 +205,7 @@ def _run(model_family, *flake_cns, upstream_family=None): flake_data = ( (FakeModel("BASE_MODEL"), "CLIP", "VAE"), ([["P", {}]], [["N", {}]], {"samples": "l"}, 1024, 1024, "", "", {"stems": [], "model_family": upstream_family}), - (30, 4.0, "euler", "simple"), + (30, 4.0, "euler", "simple", 1.0), ) entries = [{"name": n} for n in flakes] import json diff --git a/tests/test_preset_loader.py b/tests/test_preset_loader.py index a67d89b..732bd1c 100644 --- a/tests/test_preset_loader.py +++ b/tests/test_preset_loader.py @@ -464,7 +464,7 @@ def test_override_cfg_drives_the_decision_not_the_preset(): the post-override value.""" bundle = ("M", "C", "V") gen = ("POS", [["COND", {}]], "LAT", 1024, 1024, "p", "n", {"stems": []}) - samp = (30, 4.0, "euler", "simple") # preset says cfg 4 + samp = (30, 4.0, "euler", "simple", 1.0) # preset says cfg 4 _b, _g, samp2 = full_flake_node._apply_preset_overrides( '{"cfg": 1.0}', bundle, gen, samp) assert samp2[1] == 1.0