Add Smart Tile prompt override

This commit is contained in:
ttplanet
2026-07-06 21:22:35 +08:00
parent 991320a254
commit 58eb154fa0
5 changed files with 347 additions and 4 deletions
+3
View File
@@ -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 尺寸估算最终画布,并更重视细节块。 |
+251
View File
@@ -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",
+76
View File
@@ -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")
+1
View File
@@ -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");
+16 -4
View File
@@ -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) => {