diff --git a/README.md b/README.md index 971f040..c992b32 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ When subdividing a masked tile with `Grid in`, the Mask mode can crop the origin | `TTP Smart Tile Set Preview` | Preview a tile set as a contact sheet or one selected tile. | | `TTP QwenVL3 Local Loader` | Load a local QwenVL tagging model from `ComfyUI/models/text_encoders`. | | `TTP Smart Tile QwenVL Prompt Set Builder` | Generate per-tile prompts before loop processing. | +| `TTP Smart Tile Prompt Override` | Filter selected tiles and replace, prepend, append, or find/replace per-tile prompts before sampling. | | `TTP Smart Tile Semantic Rank` | Classify tiles and write recommended layer, priority, scale weight, and composite metadata. | | `TTP Smart Tile Loop Source` | Output one tile at a time for VAE Encode / sampler / VAE Decode. | | `TTP Smart Tile Loop Collect` | Collect each processed tile back into the same tile set. | @@ -68,6 +69,7 @@ When subdividing a masked tile with `Grid in`, the Mask mode can crop the origin ```text TTP Smart Tile Interactive Crop -> TTP Smart Tile QwenVL Prompt Set Builder (optional) + -> TTP Smart Tile Prompt Override (optional) -> TTP Smart Tile Semantic Rank (optional) -> TTP Smart Tile Loop Source -> VAE Encode / Sampler / VAE Decode @@ -114,6 +116,7 @@ This workflow is the recommended full Smart Tile loop. It uses `TTP Smart Tile I | `Interactive Crop` | `auto_detect_mode=sam3.1`, `default_pad=32`, `default_blend=32`, `auto_object_padding=64`, `auto_max_tiles=16` | Use `Auto Tile` to create an object-aware layout, then set `auto_detect_mode=none` if you want to freeze manual edits. / 用 `Auto Tile` 生成语义分块;如果之后要固定手动编辑结果,可以把 `auto_detect_mode` 改回 `none`。 | | `QwenVL3 Local Loader` | `qwen3vl_4b_fp8_scaled.safetensors` | Replace this with the QwenVL model installed in `ComfyUI/models/text_encoders`. / 按你本机 `models/text_encoders` 里的 QwenVL 模型替换。 | | `QwenVL Prompt Set Builder` | `reference_image_mode=first_message`, `prompt_preset=tile_img2img_prompt`, `output_language=chinese` | Builds prompts before the tile loop, then `Loop Source.prompt` feeds the text encoder. / 在循环前一次性生成每块提示词,然后由 `Loop Source.prompt` 送入文本编码。 | +| `Prompt Override` | `selector_type=label`, `selector=face`, `prompt_mode=replace` | Rewrites only matched tile prompts. Use `drop_unmatched` when only selected regions should run through an edit sampler and Assemble should keep the rest from `source_image`. / 只改写匹配 tile 的提示词;局部 edit 时可用 `drop_unmatched`,其余区域由 Assemble 的 `source_image` 底图保留。 | | `Image Upscale Prep` | `scale=2.5`, `round_to=8`, `max_megapixels=1.5`, `use_upscale_model=true` | Enlarges each tile before img2img while keeping each tile under the megapixel cap. / 采样前放大每块 tile,同时用百万像素上限控制显存。 | | `Loop Source` / `Loop Collect` | `Process All Tiles` workflow | The tile count can be 4, 8, 16, or any layout count; no manual sampler duplication is needed. / 分块数量可以变化,不需要手动复制多套 sampler。 | | `Output Size Estimate` | `focus_weighted` | Estimates the final canvas from processed tile sizes, giving more influence to focus/detail tiles. / 根据处理后的 tile 尺寸估算最终画布,并更重视细节块。 | diff --git a/TTP_toolsets.py b/TTP_toolsets.py index 9355cbe..8c33916 100644 --- a/TTP_toolsets.py +++ b/TTP_toolsets.py @@ -4087,6 +4087,255 @@ class TTP_Smart_Tile_QwenVL_Prompt_Set_Builder_Experimental: return (next_tile_set, prompt_set_json, summary) +class TTP_Smart_Tile_Prompt_Override_Experimental: + @classmethod + def INPUT_TYPES(cls): + text_modes = ["keep", "replace", "prepend", "append", "find_replace", "clear"] + return { + "required": { + "tile_set": ("TTP_SMART_TILE_SET", {"forceInput": True}), + "selector_type": ([ + "all", + "index", + "name", + "label", + "prompt_tag", + "source", + "semantic_category", + "semantic_role", + "prompt_contains", + "regex", + ], {"default": "label"}), + "selector": ("STRING", {"default": "", "multiline": False}), + "unmatched_mode": (["keep", "drop_unmatched"], {"default": "keep"}), + "prompt_mode": (text_modes, {"default": "replace"}), + "prompt_text": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False}), + "negative_mode": (text_modes, {"default": "keep"}), + "negative_text": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False}), + "find_text": ("STRING", {"default": "", "multiline": False, "dynamicPrompts": False}), + } + } + + RETURN_TYPES = ("TTP_SMART_TILE_SET", "STRING", "STRING") + RETURN_NAMES = ("tile_set", "override_json", "summary") + FUNCTION = "override_prompts" + CATEGORY = "TTP/Smart Tile" + + @staticmethod + def _selector_terms(selector): + return [ + part.strip() + for part in re.split(r"[,;\n]+", str(selector or "")) + if part.strip() + ] + + @staticmethod + def _selector_index_set(selector, count): + indexes = set() + for term in TTP_Smart_Tile_Prompt_Override_Experimental._selector_terms(selector): + cleaned = term.strip() + t_range = re.match(r"^[tT]\s*(\d+)\s*-\s*[tT]?\s*(\d+)$", cleaned) + zero_range = re.match(r"^(\d+)\s*-\s*(\d+)$", cleaned) + t_single = re.match(r"^[tT]\s*(\d+)$", cleaned) + zero_single = re.match(r"^\d+$", cleaned) + if t_range: + start = int(t_range.group(1)) - 1 + end = int(t_range.group(2)) - 1 + elif zero_range: + start = int(zero_range.group(1)) + end = int(zero_range.group(2)) + elif t_single: + start = end = int(t_single.group(1)) - 1 + elif zero_single: + start = end = int(cleaned) + else: + continue + if end < start: + start, end = end, start + for index in range(start, end + 1): + if 0 <= index < count: + indexes.add(index) + return indexes + + @staticmethod + def _match_tile(tile, index, count, selector_type, selector): + selector_type = str(selector_type or "label") + if selector_type == "all": + return True + if selector_type == "index": + return index in TTP_Smart_Tile_Prompt_Override_Experimental._selector_index_set(selector, count) + + terms = [term.lower() for term in TTP_Smart_Tile_Prompt_Override_Experimental._selector_terms(selector)] + if not terms: + return False + + fields = { + "name": [tile.get("name", "")], + "label": [tile.get("label", "")], + "prompt_tag": [tile.get("prompt_tag", "")], + "source": [tile.get("source", "")], + "semantic_category": [tile.get("semantic_category", "")], + "semantic_role": [tile.get("semantic_role", "")], + "prompt_contains": [tile.get("prompt", ""), tile.get("negative", ""), tile.get("caption", "")], + } + searchable = fields.get(selector_type) + if selector_type == "regex": + searchable = [ + str(index), + str(index + 1), + tile.get("name", ""), + tile.get("label", ""), + tile.get("prompt_tag", ""), + tile.get("source", ""), + tile.get("semantic_category", ""), + tile.get("semantic_role", ""), + tile.get("caption", ""), + tile.get("prompt", ""), + tile.get("negative", ""), + ] + text = "\n".join(str(value or "") for value in searchable) + try: + return bool(re.search(str(selector or ""), text, flags=re.IGNORECASE)) + except re.error as exc: + raise ValueError(f"Invalid Smart Tile prompt override regex: {exc}") from exc + if searchable is None: + searchable = [ + tile.get("name", ""), + tile.get("label", ""), + tile.get("prompt_tag", ""), + tile.get("source", ""), + tile.get("caption", ""), + tile.get("prompt", ""), + ] + text = "\n".join(str(value or "").lower() for value in searchable) + return any(term in text for term in terms) + + @staticmethod + def _join_text(prefix, suffix): + prefix = str(prefix or "").strip() + suffix = str(suffix or "").strip() + if prefix and suffix: + return f"{prefix}\n{suffix}" + return prefix or suffix + + @staticmethod + def _apply_text_mode(current, mode, text, find_text): + current = str(current or "") + mode = str(mode or "keep") + text = str(text or "").strip() + find_text = str(find_text or "") + if mode == "keep": + return current + if mode == "clear": + return "" + if mode == "replace": + return text + if mode == "prepend": + return TTP_Smart_Tile_Prompt_Override_Experimental._join_text(text, current) + if mode == "append": + return TTP_Smart_Tile_Prompt_Override_Experimental._join_text(current, text) + if mode == "find_replace": + return current.replace(find_text, text) if find_text else current + return current + + @staticmethod + def _position_for_tile(tile): + box = tile.get("paste_box", tile.get("core_box", [0, 0, 1, 1])) + x, y, w, h = [int(round(float(value))) for value in box[:4]] + return (x, y, x + max(1, w), y + max(1, h)) + + def override_prompts( + self, + tile_set, + selector_type="label", + selector="", + unmatched_mode="keep", + prompt_mode="replace", + prompt_text="", + negative_mode="keep", + negative_text="", + find_text="", + ): + tile_images, _tile_meta, tiles_info = _ttp_validate_tile_set(tile_set) + next_tile_set = _ttp_clone_tile_set(tile_set) + next_tiles = next_tile_set["tile_meta"]["tiles"] + next_images = list(next_tile_set.get("tile_images", [])) + next_positions = list(next_tile_set.get("positions", [])) + + kept_tiles = [] + kept_images = [] + kept_positions = [] + records = [] + matched_count = 0 + drop_unmatched = str(unmatched_mode or "keep") == "drop_unmatched" + count = len(tiles_info) + + for index, tile in enumerate(next_tiles): + matched = self._match_tile(tile, index, count, selector_type, selector) + if not matched and drop_unmatched: + continue + + updated = dict(tile) + if matched: + matched_count += 1 + before_prompt = str(updated.get("prompt", "")) + before_negative = str(updated.get("negative", "")) + updated["prompt"] = self._apply_text_mode(before_prompt, prompt_mode, prompt_text, find_text) + updated["negative"] = self._apply_text_mode(before_negative, negative_mode, negative_text, find_text) + existing_source = str(updated.get("prompt_source", "") or "") + updated["prompt_source"] = f"{existing_source}+override" if existing_source else "override" + updated["prompt_override"] = True + updated["prompt_override_selector_type"] = str(selector_type or "") + updated["prompt_override_selector"] = str(selector or "") + records.append({ + "index": int(index), + "name": updated.get("name", f"tile_{index}"), + "label": updated.get("label", ""), + "prompt_before": before_prompt, + "prompt_after": updated["prompt"], + "negative_before": before_negative, + "negative_after": updated["negative"], + }) + + kept_tiles.append(updated) + kept_images.append(next_images[index] if index < len(next_images) else tile_images[index]) + if index < len(next_positions): + kept_positions.append(next_positions[index]) + else: + kept_positions.append(self._position_for_tile(updated)) + + if not kept_tiles: + raise ValueError("Smart Tile prompt override kept no tiles. Change selector or unmatched_mode.") + + for new_index, tile in enumerate(kept_tiles): + tile["id"] = new_index + + next_tile_set["tile_meta"]["tiles"] = kept_tiles + next_tile_set["tile_images"] = kept_images + next_tile_set["positions"] = kept_positions + + override_json = json.dumps({ + "type": "ttp_smart_tile_prompt_override", + "selector_type": str(selector_type or ""), + "selector": str(selector or ""), + "unmatched_mode": str(unmatched_mode or "keep"), + "prompt_mode": str(prompt_mode or "keep"), + "negative_mode": str(negative_mode or "keep"), + "matched": int(matched_count), + "kept": int(len(kept_tiles)), + "dropped": int(count - len(kept_tiles)), + "tiles": records, + }, ensure_ascii=False, indent=2) + summary_lines = [ + f"matched={matched_count} kept={len(kept_tiles)} dropped={count - len(kept_tiles)} selector={selector_type}:{selector}" + ] + summary_lines.extend( + f'{record["index"]}:{record["name"]} -> {_ttp_compact_text(record["prompt_after"], 160)}' + for record in records + ) + return (next_tile_set, override_json, "\n".join(summary_lines)) + + class TTP_Smart_Tile_Loop_Source_Experimental: @classmethod def INPUT_TYPES(cls): @@ -6092,6 +6341,7 @@ NODE_CLASS_MAPPINGS = { "TTP_Smart_Tile_Set_Preview_Experimental": TTP_Smart_Tile_Set_Preview_Experimental, "TTP_QwenVL3_Local_Loader_Experimental": TTP_QwenVL3_Local_Loader_Experimental, "TTP_Smart_Tile_QwenVL_Prompt_Set_Builder_Experimental": TTP_Smart_Tile_QwenVL_Prompt_Set_Builder_Experimental, + "TTP_Smart_Tile_Prompt_Override_Experimental": TTP_Smart_Tile_Prompt_Override_Experimental, "TTP_Smart_Tile_Loop_Source_Experimental": TTP_Smart_Tile_Loop_Source_Experimental, "TTP_Smart_Tile_Loop_Collect_Experimental": TTP_Smart_Tile_Loop_Collect_Experimental, "TTP_Smart_Tile_Image_Upscale_Prep_Experimental": TTP_Smart_Tile_Image_Upscale_Prep_Experimental, @@ -6117,6 +6367,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TTP_Smart_Tile_Set_Preview_Experimental": "TTP Smart Tile Set Preview", "TTP_QwenVL3_Local_Loader_Experimental": "TTP QwenVL3 Local Loader", "TTP_Smart_Tile_QwenVL_Prompt_Set_Builder_Experimental": "TTP Smart Tile QwenVL Prompt Set Builder", + "TTP_Smart_Tile_Prompt_Override_Experimental": "TTP Smart Tile Prompt Override", "TTP_Smart_Tile_Loop_Source_Experimental": "TTP Smart Tile Loop Source", "TTP_Smart_Tile_Loop_Collect_Experimental": "TTP Smart Tile Loop Collect", "TTP_Smart_Tile_Image_Upscale_Prep_Experimental": "TTP Smart Tile Image Upscale Prep", diff --git a/scripts/smoke_smart_tile_grid_crop.py b/scripts/smoke_smart_tile_grid_crop.py index e6207f1..59cd3fe 100644 --- a/scripts/smoke_smart_tile_grid_crop.py +++ b/scripts/smoke_smart_tile_grid_crop.py @@ -257,6 +257,82 @@ assert_equal( ttp.TTP_Smart_Tile_QwenVL_Prompt_Set_Builder_Experimental, "prompt builder should be registered", ) +prompt_override_inputs = ttp.TTP_Smart_Tile_Prompt_Override_Experimental.INPUT_TYPES() +assert_equal(prompt_override_inputs["required"]["tile_set"][0], "TTP_SMART_TILE_SET", "prompt override should accept Smart Tile Set") +assert_equal(prompt_override_inputs["required"]["tile_set"][1].get("forceInput"), True, "prompt override tile_set should be a forceInput connection") +assert_equal("regex" in prompt_override_inputs["required"]["selector_type"][0], True, "prompt override should support regex selectors") +assert_equal(prompt_override_inputs["required"]["unmatched_mode"][0], ["keep", "drop_unmatched"], "prompt override should allow dropping unmatched tiles") +assert_equal(prompt_override_inputs["required"]["prompt_text"][1]["dynamicPrompts"], False, "prompt override text should not use dynamic prompt parsing") +assert_equal( + ttp.NODE_CLASS_MAPPINGS["TTP_Smart_Tile_Prompt_Override_Experimental"], + ttp.TTP_Smart_Tile_Prompt_Override_Experimental, + "prompt override should be registered", +) + +override_tile_set = { + "version": 1, + "type": "ttp_smart_tile_set", + "original_size": [100, 100], + "tile_meta": { + "version": 3, + "type": "ttp_smart_tile", + "storage": "tile_set", + "original_size": [100, 100], + "tiles": [ + { + "id": 0, + "name": "tile_1", + "label": "face", + "source": "paint_mask", + "core_box": [0, 0, 50, 50], + "sample_box": [0, 0, 50, 50], + "paste_box": [0, 0, 50, 50], + "prompt": "old face prompt", + "negative": "old face negative", + }, + { + "id": 1, + "name": "tile_2", + "label": "background", + "source": "manual", + "core_box": [50, 0, 50, 50], + "sample_box": [50, 0, 50, 50], + "paste_box": [50, 0, 50, 50], + "prompt": "old background prompt", + "negative": "old background negative", + }, + ], + }, + "tile_images": ["face_image", "background_image"], + "positions": [(0, 0, 50, 50), (50, 0, 100, 50)], +} +override_node = ttp.TTP_Smart_Tile_Prompt_Override_Experimental() +overridden_set, override_json, override_summary = override_node.override_prompts( + override_tile_set, + selector_type="label", + selector="face", + unmatched_mode="keep", + prompt_mode="replace", + prompt_text="edit only the face", + negative_mode="append", + negative_text="avoid changing identity", +) +assert_equal(overridden_set["tile_meta"]["tiles"][0]["prompt"], "edit only the face", "prompt override should replace matched tile prompt") +assert_equal(overridden_set["tile_meta"]["tiles"][0]["negative"], "old face negative\navoid changing identity", "prompt override should append matched tile negative") +assert_equal(overridden_set["tile_meta"]["tiles"][1]["prompt"], "old background prompt", "prompt override should keep unmatched tile prompt") +assert_equal(json.loads(override_json)["matched"], 1, "prompt override JSON should report matched tile count") +assert_equal("matched=1" in override_summary, True, "prompt override summary should report matched count") +dropped_set, _dropped_json, _dropped_summary = override_node.override_prompts( + override_tile_set, + selector_type="label", + selector="face", + unmatched_mode="drop_unmatched", + prompt_mode="replace", + prompt_text="edit only the face", +) +assert_equal(len(dropped_set["tile_meta"]["tiles"]), 1, "prompt override should drop unmatched tile metadata") +assert_equal(len(dropped_set["tile_images"]), 1, "prompt override should drop unmatched tile images") +assert_equal(dropped_set["tile_meta"]["tiles"][0]["id"], 0, "prompt override should reindex kept tile ids") loop_source_inputs = ttp.TTP_Smart_Tile_Loop_Source_Experimental.INPUT_TYPES() assert_equal(loop_source_inputs["required"]["tile_set"][0], "TTP_SMART_TILE_SET", "loop source should accept Smart Tile Set") assert_equal(loop_source_inputs["optional"]["clip"][0], "CLIP", "loop source should optionally encode tile prompts with CLIP") diff --git a/scripts/smoke_smart_tile_interactive.mjs b/scripts/smoke_smart_tile_interactive.mjs index 79bbb27..4bf939e 100644 --- a/scripts/smoke_smart_tile_interactive.mjs +++ b/scripts/smoke_smart_tile_interactive.mjs @@ -76,6 +76,7 @@ assert(/auto_paint_mask/.test(source), "tile editor should sync painted masks in assert(/Brush/.test(source), "tile editor should expose a paint brush action"); assert(/Erase/.test(source), "tile editor should expose an erase action"); assert(/Mask to Tile/.test(source), "tile editor should expose a button that commits painted masks into layout tiles"); +assert(/Mask Replace/.test(source), "tile editor should expose a button that replaces the layout with painted mask tiles"); assert(/Refresh masks/.test(source), "tile editor should expose a refresh masks action"); assert(/refreshInheritedMasks\(node, tiles, gridMaskMode\(node\)\)/.test(source), "Refresh masks should use the current grid mask mode"); assert(/function addPaintMaskTiles/.test(source), "tile editor should convert painted masks into persisted tiles"); diff --git a/web/js/ttp_smart_tile_interactive.js b/web/js/ttp_smart_tile_interactive.js index 8470008..ec3ea9b 100644 --- a/web/js/ttp_smart_tile_interactive.js +++ b/web/js/ttp_smart_tile_interactive.js @@ -645,7 +645,7 @@ function maskCropData(canvas, box) { return crop.toDataURL("image/png").split(",", 2)[1]; } -function addPaintMaskTiles(node, tiles, selectedIndex) { +function addPaintMaskTiles(node, tiles, selectedIndex, replaceExisting = false) { const canvas = ensurePaintMaskCanvas(node); const size = imageSourceSize(node); if (!canvas || !size || !paintMaskHasPixels(canvas)) { @@ -659,7 +659,7 @@ function addPaintMaskTiles(node, tiles, selectedIndex) { } const padding = Math.max(0, Math.round(Number(widgetByName(node, "auto_object_padding")?.value ?? 96))); const defaults = layoutDefaults(node); - const nextTiles = [...tiles]; + const nextTiles = replaceExisting ? [] : [...tiles]; let added = 0; for (const component of components) { if (nextTiles.length >= MAX_TILES) { @@ -700,10 +700,15 @@ function addPaintMaskTiles(node, tiles, selectedIndex) { node.ttpSmartTileStatus = `Mask has ${components.length} region(s), but max ${MAX_TILES} tiles is reached.`; return false; } - writeLayout(node, nextTiles, Math.min(nextTiles.length - 1, Math.max(0, selectedIndex + added))); + const nextSelected = replaceExisting + ? Math.max(0, added - 1) + : Math.min(nextTiles.length - 1, Math.max(0, selectedIndex + added)); + writeLayout(node, nextTiles, nextSelected); clearPaintMask(node); node.ttpSmartTilePaintMode = "off"; - node.ttpSmartTileStatus = `Mask to Tile added ${added} tile(s).`; + node.ttpSmartTileStatus = replaceExisting + ? `Mask Replace created ${added} tile(s).` + : `Mask to Tile added ${added} tile(s).`; return true; } @@ -1802,6 +1807,13 @@ function renderEditor(node) { renderEditor(node); } }, !paintMaskHasPixels(ensurePaintMaskCanvas(node)) || tiles.length >= MAX_TILES), + createButton("Mask Replace", () => { + if (addPaintMaskTiles(node, tiles, 0, true)) { + renderEditor(node); + } else { + renderEditor(node); + } + }, !paintMaskHasPixels(ensurePaintMaskCanvas(node))), createButton("Refresh masks", async () => { updateGridValues(); const refreshed = await refreshInheritedMasks(node, tiles, gridMaskMode(node)).catch((error) => {