import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; const NODE_NAME = "TTP_Smart_Tile_Interactive_Crop_Experimental"; const LOOP_SOURCE_NAME = "TTP_Smart_Tile_Loop_Source_Experimental"; const MAX_TILES = 64; const MAX_GRID_AXIS = 8; const dragThresholdPx = 3; const snapGuideThresholdPx = 10; const largeContextAreaRatio = 0.9; const STORAGE_PREFIX = "ttp_smart_tile_interactive_layout"; const GRID_MASK_MODES = ["off", "crop_mask", "crop_mask_skip_empty"]; const MASK_OVERLAY_COLORS = [ [248, 113, 113], [52, 211, 153], [96, 165, 250], [251, 191, 36], [216, 180, 254], [45, 212, 191], [251, 113, 133], [163, 230, 53], ]; const TILE_METADATA_KEYS = [ "name", "source", "label", "score", "layer", "object_id", "occlusion_priority", "priority", "importance", "pad", "blend", "object_mask", "object_mask_source", "object_mask_expand", "semantic_category", "semantic_role", "semantic_score", "recommended_scale_weight", "recommended_layer", "recommended_priority", "recommended_occlusion_priority", "recommended_composite_mode", ]; function widgetByName(node, name) { return node.widgets?.find((widget) => widget.name === name); } function setWidgetVisible(widget, visible) { if (!widget) { return; } widget.hidden = !visible; widget.computeSize = visible ? undefined : () => [0, -4]; } function scheduleCanvas(node) { node.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } function resizeNode(node) { if (node.computeSize) { const size = node.computeSize(); node.size = [Math.max(node.size?.[0] ?? 520, size[0]), size[1]]; } scheduleCanvas(node); } function clampNumber(value, min, max) { const number = Number(value); if (!Number.isFinite(number)) { return min; } return Math.max(min, Math.min(max, number)); } function roundRatio(value) { return Number(clampNumber(value, 0, 1).toFixed(6)); } function imageSourceSize(node) { if (!node) { return null; } const image = node.ttpSmartTileImage; if (image?.width > 0 && image?.height > 0) { return { width: image.width, height: image.height }; } return null; } function snapStep(node) { const value = Number(widgetByName(node, "round_to")?.value ?? 8); return Math.max(1, Math.min(128, Math.round(value))); } function minTileRatio(node, axis) { const size = imageSourceSize(node); const dimension = axis === "x" ? size?.width : size?.height; if (Number(dimension) > 0) { return Math.max(1 / Number(dimension), Math.min(0.2, 24 / Number(dimension))); } return 0.04; } function snapRatioToImage(node, ratio, axis) { const size = imageSourceSize(node); const dimension = axis === "x" ? size?.width : size?.height; if (!dimension || dimension <= 1) { return roundRatio(ratio); } const step = snapStep(node); if (ratio <= 0) { return 0; } if (ratio >= 1) { return 1; } const px = Math.round((ratio * dimension) / step) * step; return roundRatio(px / dimension); } function normalizeAxis(node, start, end, axis) { let first = clampNumber(start, 0, 1); let second = clampNumber(end, 0, 1); if (second < first) { [first, second] = [second, first]; } first = snapRatioToImage(node, first, axis); second = snapRatioToImage(node, second, axis); if (second < first) { [first, second] = [second, first]; } const minSpan = minTileRatio(node, axis); if (second - first < minSpan) { const center = (first + second) / 2; first = center - minSpan / 2; second = center + minSpan / 2; if (first < 0) { second -= first; first = 0; } if (second > 1) { first -= second - 1; second = 1; } } return [roundRatio(first), roundRatio(second)]; } function normalizeTile(node, raw) { const metadata = {}; for (const key of TILE_METADATA_KEYS) { if (raw && raw[key] !== undefined) { metadata[key] = raw[key]; } } const x0 = Number(raw?.x0 ?? raw?.x ?? 0); const y0 = Number(raw?.y0 ?? raw?.y ?? 0); const x1 = Number(raw?.x1 ?? (Number(raw?.x ?? 0) + Number(raw?.w ?? raw?.width ?? 0.5))); const y1 = Number(raw?.y1 ?? (Number(raw?.y ?? 0) + Number(raw?.h ?? raw?.height ?? 0.5))); const [left, right] = normalizeAxis(node, x0, x1, "x"); const [top, bottom] = normalizeAxis(node, y0, y1, "y"); return { ...metadata, x0: left, y0: top, x1: right, y1: bottom }; } function defaultTiles(node) { return gridTiles(node, 2, 2); } function gridTiles(node, columns, rows, bounds = { x0: 0, y0: 0, x1: 1, y1: 1 }) { const cols = Math.max(1, Math.min(MAX_GRID_AXIS, Math.round(Number(columns) || 1))); const rowCount = Math.max(1, Math.min(MAX_GRID_AXIS, Math.round(Number(rows) || 1))); const box = normalizeTile(node, bounds); const width = box.x1 - box.x0; const height = box.y1 - box.y0; const tiles = []; for (let row = 0; row < rowCount; row += 1) { for (let col = 0; col < cols; col += 1) { tiles.push(normalizeTile(node, { x0: box.x0 + width * (col / cols), y0: box.y0 + height * (row / rowCount), x1: box.x0 + width * ((col + 1) / cols), y1: box.y0 + height * ((row + 1) / rowCount), })); } } return tiles; } function gridMaskMode(node) { const mode = String(node.ttpSmartTileGridMaskMode ?? "crop_mask"); return GRID_MASK_MODES.includes(mode) ? mode : "crop_mask"; } function objectMaskPixelBox(maskData) { if (!maskData || maskData.format !== "png_base64" || !maskData.data) { return null; } const box = Array.isArray(maskData.box) ? maskData.box : [0, 0, maskData.width ?? 0, maskData.height ?? 0]; const x0 = Math.round(Number(box[0]) || 0); const y0 = Math.round(Number(box[1]) || 0); const width = Math.max(1, Math.round(Number(box[2] ?? maskData.width) || 1)); const height = Math.max(1, Math.round(Number(box[3] ?? maskData.height) || 1)); return { x0, y0, x1: x0 + width, y1: y0 + height, width, height }; } function sourceObjectMaskData(tile) { if (tile?.object_mask_source?.format === "png_base64" && tile.object_mask_source?.data) { return tile.object_mask_source; } if (tile?.object_mask?.format === "png_base64" && tile.object_mask?.data) { return tile.object_mask; } return null; } function ownObjectMaskData(tile) { if (tile?.object_mask?.format === "png_base64" && tile.object_mask?.data) { return tile.object_mask; } if (tile?.object_mask_source?.format === "png_base64" && tile.object_mask_source?.data) { return tile.object_mask_source; } return null; } function loadObjectMaskImage(maskData) { return new Promise((resolve, reject) => { if (!maskData || maskData.format !== "png_base64" || !maskData.data) { resolve(null); return; } const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Unable to decode selected tile mask.")); image.src = `data:image/png;base64,${maskData.data}`; }); } function maskOverlayEnabled(node) { return Boolean(node?.ttpSmartTileShowMasks); } function stitchMaskPreviewEnabled(node) { return Boolean(node?.ttpSmartTilePreviewStitchMask); } function maskValuesFromImageData(imageData) { const data = imageData.data; let maxRgb = 0; let minAlpha = 255; let maxAlpha = 0; for (let index = 0; index < data.length; index += 4) { maxRgb = Math.max(maxRgb, data[index], data[index + 1], data[index + 2]); minAlpha = Math.min(minAlpha, data[index + 3]); maxAlpha = Math.max(maxAlpha, data[index + 3]); } const useAlphaOnly = maxRgb === 0 && maxAlpha > minAlpha; const values = new Uint8Array(Math.floor(data.length / 4)); let hasForeground = false; for (let index = 0, pixel = 0; index < data.length; index += 4, pixel += 1) { const maskValue = useAlphaOnly ? data[index + 3] : Math.max(data[index], data[index + 1], data[index + 2]); values[pixel] = maskValue; if (maskValue > 0) { hasForeground = true; } } return { values, hasForeground }; } function boxBlurHorizontal(values, width, height, radius) { const output = new Uint8Array(values.length); const blurRadius = Math.max(1, Math.round(Number(radius) || 1)); for (let y = 0; y < height; y += 1) { const rowOffset = y * width; const prefix = new Uint32Array(width + 1); for (let x = 0; x < width; x += 1) { prefix[x + 1] = prefix[x] + values[rowOffset + x]; } for (let x = 0; x < width; x += 1) { const left = Math.max(0, x - blurRadius); const right = Math.min(width - 1, x + blurRadius); const count = Math.max(1, right - left + 1); output[rowOffset + x] = Math.round((prefix[right + 1] - prefix[left]) / count); } } return output; } function boxBlurVertical(values, width, height, radius) { const output = new Uint8Array(values.length); const blurRadius = Math.max(1, Math.round(Number(radius) || 1)); for (let x = 0; x < width; x += 1) { const prefix = new Uint32Array(height + 1); for (let y = 0; y < height; y += 1) { prefix[y + 1] = prefix[y] + values[y * width + x]; } for (let y = 0; y < height; y += 1) { const top = Math.max(0, y - blurRadius); const bottom = Math.min(height - 1, y + blurRadius); const count = Math.max(1, bottom - top + 1); output[y * width + x] = Math.round((prefix[bottom + 1] - prefix[top]) / count); } } return output; } function blurMaskValues(values, width, height, radius) { const horizontal = boxBlurHorizontal(values, width, height, radius); return boxBlurVertical(horizontal, width, height, radius); } function stitchedMaskValues(values, width, height, feather) { const hard = new Uint8Array(values.length); const threshold = Math.floor(255 * 0.05); let hasForeground = false; for (let index = 0; index < values.length; index += 1) { if (values[index] > threshold) { hard[index] = 255; hasForeground = true; } } if (!hasForeground) { return hard; } const radius = Math.max(0, Math.round(Number(feather) || 0)); if (radius <= 0) { return hard; } const stitchRadius = Math.max(1, Math.min(64, Math.round(radius * 0.25))); const expanded = slidingMaxVertical( slidingMaxHorizontal(hard, width, height, stitchRadius), width, height, stitchRadius ); const softened = blurMaskValues(expanded, width, height, stitchRadius); const output = new Uint8Array(values.length); for (let index = 0; index < values.length; index += 1) { output[index] = Math.max(hard[index], softened[index]); } return output; } function tintedMaskCanvas(maskImage, color, options = {}) { const width = Math.max(1, Math.round(maskImage?.naturalWidth || maskImage?.width || 1)); const height = Math.max(1, Math.round(maskImage?.naturalHeight || maskImage?.height || 1)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { return null; } context.clearRect(0, 0, width, height); context.drawImage(maskImage, 0, 0, width, height); const imageData = context.getImageData(0, 0, width, height); const data = imageData.data; const extracted = maskValuesFromImageData(imageData); if (!extracted.hasForeground) { return null; } const values = options.stitch ? stitchedMaskValues(extracted.values, width, height, options.feather) : extracted.values; for (let index = 0, pixel = 0; index < data.length; index += 4, pixel += 1) { const maskValue = values[pixel]; data[index] = color[0]; data[index + 1] = color[1]; data[index + 2] = color[2]; data[index + 3] = Math.round(maskValue * 0.52); } context.putImageData(imageData, 0, 0); return canvas; } async function renderMaskOverlay(node, canvas, tiles) { const sourceSize = imageSourceSize(node); const context = canvas?.getContext?.("2d", { willReadFrequently: true }); if (!sourceSize || !context) { return; } const token = Symbol("mask-overlay"); node.ttpSmartTileMaskOverlayToken = token; canvas.width = sourceSize.width; canvas.height = sourceSize.height; context.clearRect(0, 0, canvas.width, canvas.height); for (const [index, tile] of tiles.entries()) { if (node.ttpSmartTileMaskOverlayToken !== token) { return; } const maskData = ownObjectMaskData(tile); const maskBox = objectMaskPixelBox(maskData); if (!maskData?.data || !maskBox) { continue; } const maskImage = await loadObjectMaskImage(maskData); if (node.ttpSmartTileMaskOverlayToken !== token || !maskImage) { return; } const color = MASK_OVERLAY_COLORS[index % MASK_OVERLAY_COLORS.length]; const tintedMask = tintedMaskCanvas(maskImage, color, { stitch: stitchMaskPreviewEnabled(node), feather: Number(tile?.blend ?? layoutDefaults(node).blend ?? 0), }); if (!tintedMask) { continue; } context.drawImage( tintedMask, maskBox.x0, maskBox.y0, maskBox.width, maskBox.height ); } } function maskCanvasHasForeground(canvas) { const context = canvas?.getContext?.("2d", { willReadFrequently: true }); if (!context || !canvas.width || !canvas.height) { return false; } const data = context.getImageData(0, 0, canvas.width, canvas.height).data; for (let index = 0; index < data.length; index += 4) { if (data[index] > 0 || data[index + 1] > 0 || data[index + 2] > 0) { return true; } } return false; } function tileAreaRatio(tile) { const width = Math.max(0, Number(tile?.x1 ?? 0) - Number(tile?.x0 ?? 0)); const height = Math.max(0, Number(tile?.y1 ?? 0) - Number(tile?.y0 ?? 0)); return Math.max(0, Math.min(1, width * height)); } function tileSearchText(tile) { return [ tile?.label, tile?.name, tile?.source, tile?.semantic_category, tile?.semantic_role, tile?.recommended_composite_mode, ].filter(Boolean).join(" ").toLowerCase(); } function isDetailTileText(text) { return [ "face", "head", "eye", "eyes", "eyelash", "eyebrow", "glasses", "mouth", "lip", "lips", "teeth", "nose", "ear", "ears", "hand", "hands", "finger", "fingers", "text", "letter", "logo", "jewelry", "ornament", "detail", "focus", ].some((word) => text.includes(word)); } function isLargeContextTile(tile) { const text = tileSearchText(tile); if (/(background|context|full image|full_image|large context)/.test(text)) { return true; } return tileAreaRatio(tile) >= largeContextAreaRatio && !isDetailTileText(text); } function contextGridTileMetadata(sourceTile, childIndex) { const baseName = String(sourceTile?.name || sourceTile?.label || "large_context").replace(/\s+/g, "_"); const rawLabel = String(sourceTile?.label || "large context"); const label = /context|background/i.test(rawLabel) ? rawLabel : `${rawLabel} large context`; const metadata = { name: `${baseName}_context_grid_${childIndex + 1}`, source: sourceTile?.source || "auto_context", label, priority: Math.min(10, Number(sourceTile?.priority ?? 10) || 10), importance: Math.min(0.35, Number(sourceTile?.importance ?? 0.35) || 0.35), layer: 0, object_id: 0, occlusion_priority: 0, semantic_category: "background", semantic_role: "context", recommended_layer: 0, recommended_priority: 10, recommended_occlusion_priority: 0, recommended_composite_mode: "context", }; if (sourceTile?.pad !== undefined) { metadata.pad = sourceTile.pad; } if (sourceTile?.blend !== undefined) { metadata.blend = sourceTile.blend; } return metadata; } function inheritedGridTileMetadata(sourceTile, childIndex) { if (isLargeContextTile(sourceTile)) { return contextGridTileMetadata(sourceTile, childIndex); } const metadata = {}; for (const key of TILE_METADATA_KEYS) { if (key !== "name" && key !== "object_mask" && sourceTile?.[key] !== undefined) { metadata[key] = sourceTile[key]; } } const baseName = String(sourceTile?.name || sourceTile?.label || "tile").replace(/\s+/g, "_"); metadata.name = `${baseName}_grid_${childIndex + 1}`; if (sourceTile?.label && !metadata.label) { metadata.label = sourceTile.label; } if (sourceTile?.source && !metadata.source) { metadata.source = sourceTile.source; } return metadata; } function backgroundGridTileMetadata(childIndex) { return { name: `grid_empty_${childIndex + 1}`, source: "grid_empty_mask", label: "background gap", priority: 5, importance: 0.35, layer: 0, object_id: 0, occlusion_priority: 0, }; } function cropObjectMaskForTile(node, maskImage, maskBox, tile) { const rect = tilePixelRect(node, tile); if (!rect || !maskImage || !maskBox) { return null; } const tileX1 = rect.x0 + rect.width; const tileY1 = rect.y0 + rect.height; const x0 = Math.max(rect.x0, maskBox.x0); const y0 = Math.max(rect.y0, maskBox.y0); const x1 = Math.min(tileX1, maskBox.x1); const y1 = Math.min(tileY1, maskBox.y1); const width = Math.max(0, x1 - x0); const height = Math.max(0, y1 - y0); if (width <= 0 || height <= 0) { return null; } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { return null; } context.drawImage( maskImage, x0 - maskBox.x0, y0 - maskBox.y0, width, height, 0, 0, width, height ); if (!maskCanvasHasForeground(canvas)) { return null; } return { format: "png_base64", box: [Math.round(x0), Math.round(y0), Math.round(width), Math.round(height)], width: Math.round(width), height: Math.round(height), data: canvas.toDataURL("image/png").split(",", 2)[1], }; } async function gridTilesWithInheritedMask(node, sourceTile, columns, rows, mode) { const children = gridTiles(node, columns, rows, sourceTile); if (mode === "off") { return { tiles: children.map((child, index) => normalizeTile(node, { ...child, ...inheritedGridTileMetadata(sourceTile, index) })), cropped: 0, skipped: 0, emptied: 0, }; } const maskData = sourceObjectMaskData(sourceTile); const maskBox = objectMaskPixelBox(maskData); const maskImage = maskBox ? await loadObjectMaskImage(maskData) : null; const result = []; let cropped = 0; let skipped = 0; let emptied = 0; for (const [index, child] of children.entries()) { const metadata = inheritedGridTileMetadata(sourceTile, index); const nextTile = { ...child, ...metadata }; const childMask = cropObjectMaskForTile(node, maskImage, maskBox, child); if (childMask) { nextTile.object_mask = childMask; nextTile.object_mask_source = maskData; cropped += 1; } else if (maskBox) { delete nextTile.object_mask; delete nextTile.object_mask_source; if (mode === "crop_mask_skip_empty") { skipped += 1; continue; } Object.assign(nextTile, backgroundGridTileMetadata(index)); emptied += 1; } result.push(normalizeTile(node, nextTile)); } return { tiles: result, cropped, skipped, emptied }; } async function refreshInheritedMasks(node, tiles, mode) { const result = []; const imageCache = new Map(); let refreshed = 0; let skipped = 0; let emptied = 0; let unchanged = 0; const loadCachedMask = async (maskData) => { const maskBox = objectMaskPixelBox(maskData); if (!maskBox) { return { maskBox: null, maskImage: null }; } const key = `${maskBox.x0},${maskBox.y0},${maskBox.width},${maskBox.height}:${maskData.data}`; let maskImage = imageCache.get(key); if (!maskImage) { maskImage = await loadObjectMaskImage(maskData); imageCache.set(key, maskImage); } return { maskBox, maskImage }; }; for (const [index, tile] of tiles.entries()) { const sourceMask = sourceObjectMaskData(tile); if (!sourceMask?.data) { unchanged += 1; result.push(tile); continue; } const { maskBox, maskImage } = await loadCachedMask(sourceMask); const nextTile = { ...tile }; if (isLargeContextTile(tile)) { const contextMetadata = contextGridTileMetadata(tile, index); contextMetadata.name = tile?.name || contextMetadata.name; Object.assign(nextTile, contextMetadata); } const refreshedMask = cropObjectMaskForTile(node, maskImage, maskBox, tile); if (refreshedMask) { nextTile.object_mask = refreshedMask; nextTile.object_mask_source = sourceMask; refreshed += 1; result.push(normalizeTile(node, nextTile)); } else if (mode === "crop_mask_skip_empty") { skipped += 1; } else { delete nextTile.object_mask; delete nextTile.object_mask_source; Object.assign(nextTile, backgroundGridTileMetadata(index)); emptied += 1; result.push(normalizeTile(node, nextTile)); } } return { tiles: result, refreshed, skipped, emptied, unchanged }; } async function mergedObjectMaskForTiles(node, tiles, selectedIndexes) { const entries = []; for (const index of selectedIndexes) { const maskData = ownObjectMaskData(tiles[index]); const maskBox = objectMaskPixelBox(maskData); if (!maskData?.data || !maskBox) { continue; } const maskImage = await loadObjectMaskImage(maskData); if (!maskImage) { continue; } entries.push({ maskImage, maskBox }); } if (!entries.length) { return null; } const x0 = Math.min(...entries.map((entry) => entry.maskBox.x0)); const y0 = Math.min(...entries.map((entry) => entry.maskBox.y0)); const x1 = Math.max(...entries.map((entry) => entry.maskBox.x1)); const y1 = Math.max(...entries.map((entry) => entry.maskBox.y1)); const width = Math.max(1, x1 - x0); const height = Math.max(1, y1 - y0); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { return null; } context.clearRect(0, 0, width, height); context.globalCompositeOperation = "lighter"; for (const entry of entries) { context.drawImage( entry.maskImage, entry.maskBox.x0 - x0, entry.maskBox.y0 - y0, entry.maskBox.width, entry.maskBox.height ); } context.globalCompositeOperation = "source-over"; if (!maskCanvasHasForeground(canvas)) { return null; } const maskData = { format: "png_base64", box: [Math.round(x0), Math.round(y0), Math.round(width), Math.round(height)], width: Math.round(width), height: Math.round(height), data: canvas.toDataURL("image/png").split(",", 2)[1], }; return { maskData, maskBox: { x0, y0, x1, y1, width, height }, maskImage: canvas, count: entries.length, }; } async function mergeSelectedMasks(node, tiles, selectedIndexes, selectedIndex) { if (selectedIndexes.length < 2) { node.ttpSmartTileStatus = "Select at least two tiles."; return false; } const merged = await mergedObjectMaskForTiles(node, tiles, selectedIndexes); if (!merged) { node.ttpSmartTileStatus = "Selected tiles have no masks to merge."; return false; } const selectedSet = new Set(selectedIndexes); let updated = 0; const nextTiles = tiles.map((tile, index) => { if (!selectedSet.has(index)) { return tile; } const nextTile = { ...tile }; const cropped = cropObjectMaskForTile(node, merged.maskImage, merged.maskBox, nextTile); if (cropped) { nextTile.object_mask = cropped; nextTile.object_mask_source = merged.maskData; updated += 1; } else { delete nextTile.object_mask; delete nextTile.object_mask_source; } return normalizeTile(node, nextTile); }); if (!updated) { node.ttpSmartTileStatus = "Merged mask does not overlap selected tiles."; return false; } writeLayout(node, nextTiles, selectedIndex, selectedIndexes); node.ttpSmartTileStatus = `Merged ${merged.count} mask(s) across ${updated} tile(s).`; return true; } async function mergeSelectedTiles(node, tiles, selectedIndexes, selectedIndex) { if (selectedIndexes.length < 2) { node.ttpSmartTileStatus = "Select at least two tiles."; return false; } const selectedSet = new Set(selectedIndexes); const selectedTiles = selectedIndexes.map((index) => tiles[index]).filter(Boolean); if (selectedTiles.length < 2) { return false; } const primaryIndex = selectedSet.has(selectedIndex) ? selectedIndex : selectedIndexes[0]; const primaryTile = tiles[primaryIndex] ?? selectedTiles[0]; const x0 = Math.min(...selectedTiles.map((tile) => Number(tile.x0))); const y0 = Math.min(...selectedTiles.map((tile) => Number(tile.y0))); const x1 = Math.max(...selectedTiles.map((tile) => Number(tile.x1))); const y1 = Math.max(...selectedTiles.map((tile) => Number(tile.y1))); const selectorText = selectedIndexes.map((index) => `T${index + 1}`).join("+"); const mergedTile = normalizeTile(node, { ...primaryTile, x0, y0, x1, y1, name: `merged_${selectorText.toLowerCase()}`, source: "manual_merge", label: `merged ${selectorText}`, }); const mergedMask = await mergedObjectMaskForTiles(node, tiles, selectedIndexes); if (mergedMask) { const cropped = cropObjectMaskForTile(node, mergedMask.maskImage, mergedMask.maskBox, mergedTile); if (cropped) { mergedTile.object_mask = cropped; mergedTile.object_mask_source = mergedMask.maskData; } else { delete mergedTile.object_mask; delete mergedTile.object_mask_source; } } else { delete mergedTile.object_mask; delete mergedTile.object_mask_source; } const insertIndex = Math.min(...selectedIndexes); const remaining = tiles.filter((_tile, index) => !selectedSet.has(index)); remaining.splice(insertIndex, 0, mergedTile); writeLayout(node, remaining, insertIndex, [insertIndex]); node.ttpSmartTileStatus = mergedMask ? `Merged ${selectedIndexes.length} tile(s) with ${mergedMask.count} mask(s).` : `Merged ${selectedIndexes.length} tile(s).`; return true; } function sourceStorageKey(node) { const size = imageSourceSize(node); const sizeKey = size ? `${Math.round(size.width)}x${Math.round(size.height)}` : "unknown"; return `${STORAGE_PREFIX}:${node.id ?? node.title ?? "node"}:${sizeKey}`; } function readStoredLayout(node) { try { const raw = globalThis.localStorage?.getItem(sourceStorageKey(node)); if (!raw) { return null; } const value = JSON.parse(raw); if (!Array.isArray(value?.tiles) || !value.tiles.length) { return null; } return value.tiles.slice(0, MAX_TILES).map((tile) => normalizeTile(node, tile)); } catch { return null; } } function storeLayout(node, tiles) { try { const size = imageSourceSize(node); globalThis.localStorage?.setItem( sourceStorageKey(node), JSON.stringify({ source_size: size ? [Math.round(size.width), Math.round(size.height)] : null, tiles, }) ); } catch {} } function parseLayout(node) { const widget = widgetByName(node, "layout_json"); let value = {}; try { value = JSON.parse(widget?.value || "{}"); } catch { value = {}; } if (Array.isArray(value?.tiles) && value.tiles.length) { return value.tiles.slice(0, MAX_TILES).map((tile) => normalizeTile(node, tile)); } const stored = readStoredLayout(node); return stored ?? defaultTiles(node); } function layoutDefaults(node) { return { pad: Math.max(0, Math.round(Number(widgetByName(node, "default_pad")?.value ?? 128))), blend: Math.max(0, Math.round(Number(widgetByName(node, "default_blend")?.value ?? 64))), priority: 50, importance: 1.0, }; } function ensurePaintMaskCanvas(node) { const size = imageSourceSize(node); if (!size) { return null; } const width = Math.max(1, Math.round(size.width)); const height = Math.max(1, Math.round(size.height)); let canvas = node.ttpSmartTilePaintCanvas; if (!canvas || canvas.width !== width || canvas.height !== height) { canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; node.ttpSmartTilePaintCanvas = canvas; const widget = widgetByName(node, "auto_paint_mask"); if (widget) { widget.value = ""; } } return canvas; } function paintMaskHasPixels(canvas) { if (!canvas) { return false; } const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { return false; } const data = context.getImageData(0, 0, canvas.width, canvas.height).data; for (let index = 3; index < data.length; index += 4) { if (data[index] > 0) { return true; } } return false; } function syncPaintMaskWidget(node) { const widget = widgetByName(node, "auto_paint_mask"); if (!widget) { return; } const canvas = ensurePaintMaskCanvas(node); if (!paintMaskHasPixels(canvas)) { widget.value = ""; return; } widget.value = JSON.stringify({ format: "png_base64", width: canvas.width, height: canvas.height, data: canvas.toDataURL("image/png").split(",", 2)[1], }); } function clearPaintMask(node) { const canvas = ensurePaintMaskCanvas(node); if (canvas) { canvas.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); } syncPaintMaskWidget(node); } function paintMaskComponents(canvas, minArea = 24) { const context = canvas?.getContext("2d", { willReadFrequently: true }); if (!canvas || !context) { return []; } const { width, height } = canvas; const data = context.getImageData(0, 0, width, height).data; const visited = new Uint8Array(width * height); const components = []; const isPainted = (x, y) => data[(y * width + x) * 4 + 3] > 8; for (let startY = 0; startY < height; startY += 1) { for (let startX = 0; startX < width; startX += 1) { const startIndex = startY * width + startX; if (visited[startIndex] || !isPainted(startX, startY)) { visited[startIndex] = 1; continue; } const stack = [[startX, startY]]; visited[startIndex] = 1; let x0 = startX; let y0 = startY; let x1 = startX + 1; let y1 = startY + 1; let area = 0; while (stack.length) { const [x, y] = stack.pop(); area += 1; x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x + 1); y1 = Math.max(y1, y + 1); for (let nextY = Math.max(0, y - 1); nextY <= Math.min(height - 1, y + 1); nextY += 1) { for (let nextX = Math.max(0, x - 1); nextX <= Math.min(width - 1, x + 1); nextX += 1) { const nextIndex = nextY * width + nextX; if (!visited[nextIndex]) { visited[nextIndex] = 1; if (isPainted(nextX, nextY)) { stack.push([nextX, nextY]); } } } } } if (area >= minArea) { components.push({ x0, y0, x1, y1, area }); } } } return components; } function slidingMaxHorizontal(values, width, height, radius) { const output = new Uint8Array(values.length); for (let y = 0; y < height; y += 1) { const rowOffset = y * width; const deque = []; let head = 0; let added = -1; for (let x = 0; x < width; x += 1) { const limit = Math.min(width - 1, x + radius); while (added < limit) { added += 1; const value = values[rowOffset + added]; while (deque.length > head && values[rowOffset + deque[deque.length - 1]] <= value) { deque.pop(); } deque.push(added); } const minX = x - radius; while (deque.length > head && deque[head] < minX) { head += 1; } output[rowOffset + x] = deque.length > head ? values[rowOffset + deque[head]] : 0; } } return output; } function slidingMaxVertical(values, width, height, radius) { const output = new Uint8Array(values.length); for (let x = 0; x < width; x += 1) { const deque = []; let head = 0; let added = -1; for (let y = 0; y < height; y += 1) { const limit = Math.min(height - 1, y + radius); while (added < limit) { added += 1; const value = values[added * width + x]; while (deque.length > head && values[deque[deque.length - 1] * width + x] <= value) { deque.pop(); } deque.push(added); } const minY = y - radius; while (deque.length > head && deque[head] < minY) { head += 1; } output[y * width + x] = deque.length > head ? values[deque[head] * width + x] : 0; } } return output; } function expandMaskImageData(imageData, width, height, expandPx) { const radius = Math.max(0, Math.min(2048, Math.round(Number(expandPx) || 0))); if (radius <= 0) { return imageData; } const source = new Uint8Array(width * height); const data = imageData.data; for (let index = 0; index < source.length; index += 1) { const offset = index * 4; source[index] = Math.max(data[offset], data[offset + 1], data[offset + 2], data[offset + 3]); } const horizontal = slidingMaxHorizontal(source, width, height, radius); const expanded = slidingMaxVertical(horizontal, width, height, radius); for (let index = 0; index < expanded.length; index += 1) { const value = expanded[index]; const offset = index * 4; data[offset] = value; data[offset + 1] = value; data[offset + 2] = value; data[offset + 3] = 255; } return imageData; } function maskCropData(canvas, box, expandPx = 0) { const width = Math.max(1, Math.round(box.x1 - box.x0)); const height = Math.max(1, Math.round(box.y1 - box.y0)); const crop = document.createElement("canvas"); crop.width = width; crop.height = height; const context = crop.getContext("2d", { willReadFrequently: true }); context.drawImage(canvas, box.x0, box.y0, width, height, 0, 0, width, height); const radius = Math.max(0, Math.round(Number(expandPx) || 0)); if (radius > 0) { const imageData = context.getImageData(0, 0, width, height); context.putImageData(expandMaskImageData(imageData, width, height, radius), 0, 0); } return crop.toDataURL("image/png").split(",", 2)[1]; } function addPaintMaskTiles(node, tiles, selectedIndex, replaceExisting = false) { const canvas = ensurePaintMaskCanvas(node); const size = imageSourceSize(node); if (!canvas || !size || !paintMaskHasPixels(canvas)) { node.ttpSmartTileStatus = "Paint a mask first."; return false; } const components = paintMaskComponents(canvas); if (!components.length) { node.ttpSmartTileStatus = "Paint mask is too small."; return false; } const padding = Math.max(0, Math.round(Number(widgetByName(node, "auto_object_padding")?.value ?? 96))); const maskExpand = Math.max(0, Math.round(Number(widgetByName(node, "auto_mask_expand")?.value ?? 16))); const defaults = layoutDefaults(node); const nextTiles = replaceExisting ? [] : [...tiles]; let added = 0; for (const component of components) { if (nextTiles.length >= MAX_TILES) { break; } const box = { x0: Math.max(0, component.x0 - padding), y0: Math.max(0, component.y0 - padding), x1: Math.min(canvas.width, component.x1 + padding), y1: Math.min(canvas.height, component.y1 + padding), }; const width = Math.max(1, box.x1 - box.x0); const height = Math.max(1, box.y1 - box.y0); nextTiles.push(normalizeTile(node, { x0: box.x0 / canvas.width, y0: box.y0 / canvas.height, x1: box.x1 / canvas.width, y1: box.y1 / canvas.height, source: "paint_mask", label: `paint mask ${added + 1}`, pad: defaults.pad, blend: defaults.blend, priority: 140, importance: 1.0, layer: 5, occlusion_priority: 3200 + nextTiles.length, object_mask_expand: maskExpand, object_mask: { format: "png_base64", box: [Math.round(box.x0), Math.round(box.y0), Math.round(width), Math.round(height)], width: Math.round(width), height: Math.round(height), data: maskCropData(canvas, box, maskExpand), }, })); added += 1; } if (!added) { node.ttpSmartTileStatus = `Mask has ${components.length} region(s), but max ${MAX_TILES} tiles is reached.`; return false; } 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 = replaceExisting ? `Mask Replace created ${added} tile(s).` : `Mask to Tile added ${added} tile(s).`; return true; } function selectedTileIndexes(node, count, fallbackIndex = 0) { const fallback = Math.max(0, Math.min(Math.max(0, count - 1), Number(fallbackIndex ?? 0))); const raw = Array.isArray(node.ttpSmartTileSelectedIndexes) ? node.ttpSmartTileSelectedIndexes : [node.ttpSmartTileSelectedIndex ?? fallback]; const result = []; const seen = new Set(); for (const value of raw) { const index = Math.round(Number(value)); if (Number.isFinite(index) && index >= 0 && index < count && !seen.has(index)) { result.push(index); seen.add(index); } } if (!result.length && count > 0) { result.push(fallback); } return result.sort((a, b) => a - b); } function setTileSelection(node, index, count, event = null) { const safeIndex = Math.max(0, Math.min(Math.max(0, count - 1), Math.round(Number(index) || 0))); const multi = boolEventModifier(event); if (!multi) { node.ttpSmartTileSelectedIndex = safeIndex; node.ttpSmartTileSelectedIndexes = [safeIndex]; return; } const current = selectedTileIndexes(node, count, safeIndex); const selected = new Set(current); if (selected.has(safeIndex) && selected.size > 1) { selected.delete(safeIndex); } else { selected.add(safeIndex); } const next = [...selected].sort((a, b) => a - b); node.ttpSmartTileSelectedIndexes = next; node.ttpSmartTileSelectedIndex = safeIndex; } function boolEventModifier(event) { return Boolean(event?.shiftKey) || Boolean(event?.metaKey) || Boolean(event?.ctrlKey); } function writeLayout(node, tiles, selectedIndex = 0, selectedIndexes = null) { const normalizedTiles = (Array.isArray(tiles) && tiles.length ? tiles : defaultTiles(node)) .slice(0, MAX_TILES) .map((tile, index) => ({ name: `tile_${index + 1}`, ...normalizeTile(node, tile), })); const size = imageSourceSize(node); const layout = { version: 1, type: "ttp_smart_tile_interactive_layout", source_size: size ? [Math.round(size.width), Math.round(size.height)] : null, defaults: layoutDefaults(node), tiles: normalizedTiles, }; const serialized = JSON.stringify(layout); const widget = widgetByName(node, "layout_json"); if (widget && widget.value !== serialized) { widget.value = serialized; } node.ttpSmartTileLayout = layout; node.ttpSmartTileSelectedIndex = Math.max(0, Math.min(normalizedTiles.length - 1, Number(selectedIndex ?? 0))); node.ttpSmartTileSelectedIndexes = selectedIndexes === null ? [node.ttpSmartTileSelectedIndex] : selectedTileIndexes({ ttpSmartTileSelectedIndexes: selectedIndexes }, normalizedTiles.length, node.ttpSmartTileSelectedIndex); storeLayout(node, normalizedTiles); } function uniqueSorted(values) { const sorted = values .map((value) => roundRatio(value)) .filter((value) => Number.isFinite(value)) .sort((a, b) => a - b); const result = []; for (const value of sorted) { if (!result.length || Math.abs(value - result[result.length - 1]) > 1e-6) { result.push(value); } } return result; } function analyzeCoverage(tiles) { const normalized = (Array.isArray(tiles) ? tiles : []).map((tile) => normalizeTile(null, tile)); const xEdges = uniqueSorted([0, 1, ...normalized.flatMap((tile) => [tile.x0, tile.x1])]); const yEdges = uniqueSorted([0, 1, ...normalized.flatMap((tile) => [tile.y0, tile.y1])]); const rowRuns = []; let uncoveredArea = 0; for (let yi = 0; yi < yEdges.length - 1; yi += 1) { const y0 = yEdges[yi]; const y1 = yEdges[yi + 1]; let run = null; for (let xi = 0; xi < xEdges.length - 1; xi += 1) { const x0 = xEdges[xi]; const x1 = xEdges[xi + 1]; const cx = (x0 + x1) / 2; const cy = (y0 + y1) / 2; const covered = normalized.some( (tile) => cx >= tile.x0 - 1e-6 && cx <= tile.x1 + 1e-6 && cy >= tile.y0 - 1e-6 && cy <= tile.y1 + 1e-6 ); if (covered) { if (run) { rowRuns.push(run); run = null; } continue; } uncoveredArea += (x1 - x0) * (y1 - y0); if (run && Math.abs(run.x1 - x0) <= 1e-6) { run.x1 = x1; } else { if (run) { rowRuns.push(run); } run = { x0, y0, x1, y1 }; } } if (run) { rowRuns.push(run); } } const gaps = []; const activeBySpan = new Map(); for (const run of rowRuns.sort((a, b) => a.x0 - b.x0 || a.x1 - b.x1 || a.y0 - b.y0 || a.y1 - b.y1)) { const key = `${run.x0}:${run.x1}`; const previous = activeBySpan.get(key); if (previous && Math.abs(previous.y1 - run.y0) <= 1e-6) { previous.y1 = run.y1; } else { const gap = { ...run }; gaps.push(gap); activeBySpan.set(key, gap); } } gaps.sort((a, b) => (b.x1 - b.x0) * (b.y1 - b.y0) - (a.x1 - a.x0) * (a.y1 - a.y0)); return { gaps: gaps.map((gap) => ({ x0: roundRatio(gap.x0), y0: roundRatio(gap.y0), x1: roundRatio(gap.x1), y1: roundRatio(gap.y1), })), uncoveredArea, }; } function fillTileGaps(node, tiles, maxTiles = MAX_TILES) { const nextTiles = (Array.isArray(tiles) ? tiles : []).map((tile) => ({ ...tile })); let coverageState = analyzeCoverage(nextTiles); let added = 0; while (coverageState.gaps.length && nextTiles.length < maxTiles) { const previousArea = coverageState.uncoveredArea; const gapTile = normalizeTile(node, coverageState.gaps[0]); nextTiles.push({ ...gapTile, name: `auto_gap_${added + 1}`, source: "auto_gap", label: "background gap", priority: 5, importance: 0.35, layer: 0, object_id: 0, occlusion_priority: 0, }); added += 1; coverageState = analyzeCoverage(nextTiles); if (coverageState.uncoveredArea >= previousArea - 1e-8) { nextTiles.pop(); added -= 1; break; } } return { tiles: nextTiles, coverage: coverageState, added, }; } function autoMaxTiles(node) { const widget = widgetByName(node, "auto_max_tiles"); const value = Math.round(Number(widget?.value ?? MAX_TILES) || MAX_TILES); return Math.max(1, Math.min(MAX_TILES, value)); } function tilePixelRect(node, tile) { const size = imageSourceSize(node); if (!size) { return null; } const x0 = Math.round(tile.x0 * size.width); const y0 = Math.round(tile.y0 * size.height); const x1 = Math.round(tile.x1 * size.width); const y1 = Math.round(tile.y1 * size.height); return { x0, y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0) }; } function createButton(label, onClick, disabled = false) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.disabled = disabled; button.style.cssText = [ "font:inherit", "font-size:12px", "color:" + (disabled ? "rgba(15,23,42,.45)" : "#0f172a"), "background:#e2e8f0", "border:1px solid #cbd5e1", "border-radius:4px", "padding:4px 7px", "cursor:" + (disabled ? "default" : "pointer"), ].join(";"); if (!disabled) { button.onclick = onClick; } return button; } function createNumberInput(value, min, max) { const input = document.createElement("input"); input.type = "number"; input.min = String(min); input.max = String(max); input.step = "1"; input.value = String(value); input.style.cssText = [ "box-sizing:border-box", "width:54px", "font:inherit", "font-size:12px", "color:#e5e7eb", "background:#172033", "border:1px solid rgba(226,232,240,.35)", "border-radius:4px", "padding:4px 5px", ].join(";"); return input; } function createSelect(value, options) { const select = document.createElement("select"); for (const option of options) { const item = document.createElement("option"); item.value = option.value; item.textContent = option.label; select.append(item); } select.value = value; select.style.cssText = [ "box-sizing:border-box", "height:28px", "font:inherit", "font-size:12px", "color:#e5e7eb", "background:#172033", "border:1px solid rgba(226,232,240,.35)", "border-radius:4px", "padding:3px 5px", ].join(";"); return select; } function parseAnnotatedImageName(value) { let name = String(value ?? "").trim(); let type = "input"; for (const candidate of ["input", "output", "temp"]) { const suffix = ` [${candidate}]`; if (name.endsWith(suffix)) { name = name.slice(0, -suffix.length).trim(); type = candidate; break; } } name = name.replace(/\\/g, "/"); const slash = name.lastIndexOf("/"); const subfolder = slash >= 0 ? name.slice(0, slash) : ""; const filename = slash >= 0 ? name.slice(slash + 1) : name; return { filename, subfolder, type }; } function inputImageUrl(value) { const { filename, subfolder, type } = parseAnnotatedImageName(value); if (!filename) { return ""; } const params = new URLSearchParams({ filename, subfolder, type }); return api.apiURL(`/view?${params.toString()}`); } async function loadSelectedInputImage(node, force = false) { const value = String(widgetByName(node, "image")?.value ?? ""); if (!value) { node.ttpSmartTileImage = null; return; } if (!force && value === node.ttpSmartTileLoadedImageName) { return; } node.ttpSmartTileLoadedImageName = value; const url = inputImageUrl(value); try { await new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { node.ttpSmartTileImage = { url, filename: value, width: image.naturalWidth, height: image.naturalHeight, element: image, }; const layout = readStoredLayout(node) ?? parseLayout(node); writeLayout(node, layout, 0); resolve(); }; image.onerror = reject; image.src = url; }); node.ttpSmartTileStatus = ""; } catch { node.ttpSmartTileStatus = "Selected input image could not be previewed."; } renderEditor(node); } async function inferSmartTileLayout(node, options = {}) { const modeWidget = widgetByName(node, "auto_detect_mode"); if (options.modeOverride && modeWidget) { modeWidget.value = String(options.modeOverride); } const mode = String(modeWidget?.value ?? "none"); if (mode === "none") { node.ttpSmartTileStatus = "Set auto detect mode to sam3.1 or qwenvl3 before inference."; renderEditor(node); return; } const fillGaps = options.fillGaps !== false; const actionLabel = String(options.label || (fillGaps ? "Auto Tile" : "Auto SAM")); const requestWidget = widgetByName(node, "auto_detect_request"); if (requestWidget) { requestWidget.value = Number(requestWidget.value ?? 0) + 1; } node.ttpSmartTileAutoFillGaps = fillGaps; node.ttpSmartTileInferenceLabel = actionLabel; node.ttpSmartTileStatus = `Queued ${actionLabel} (${mode}) inference for this node only...`; renderEditor(node); try { await app.queuePrompt(0, 1, [String(node.id)]); } catch (error) { if (requestWidget) { requestWidget.value = 0; } node.ttpSmartTileStatus = error?.message || "Could not queue inference."; renderEditor(node); } } function applyInferenceResult(detail) { if (!detail?.node_id || !app.graph) { return; } const node = app.graph.getNodeById?.(Number(detail.node_id)) ?? app.graph._nodes_by_id?.[detail.node_id]; if (!node || (node.comfyClass ?? node.type) !== NODE_NAME) { return; } const requestWidget = widgetByName(node, "auto_detect_request"); if (requestWidget) { requestWidget.value = 0; } const fillGaps = node.ttpSmartTileAutoFillGaps !== false; const actionLabel = String(node.ttpSmartTileInferenceLabel || (fillGaps ? "Auto Tile" : "Auto SAM")); const statusParts = []; if (detail.message) { statusParts.push(detail.message); } else { statusParts.push(detail.ok ? "Inference finished." : "Inference failed."); } if (detail.layout_json) { try { const layout = JSON.parse(detail.layout_json); if (Array.isArray(layout?.tiles) && layout.tiles.length) { if (fillGaps) { const filled = fillTileGaps(node, layout.tiles, autoMaxTiles(node)); writeLayout(node, filled.tiles, 0); if (filled.added > 0) { statusParts.push(`Auto Tile added ${filled.added} gap tile(s).`); } if (filled.coverage.gaps.length > 0) { statusParts.push(`${filled.coverage.gaps.length} gap(s) remain.`); } } else { writeLayout(node, layout.tiles, 0); const coverage = analyzeCoverage(layout.tiles); statusParts.push(`${actionLabel} kept detected tile(s) without gap filling.`); if (coverage.gaps.length > 0) { statusParts.push(`${coverage.gaps.length} gap(s) remain.`); } } } } catch (error) { node.ttpSmartTileStatus = error?.message || "Inference returned invalid layout."; renderEditor(node); return; } } node.ttpSmartTileStatus = statusParts.join(" "); renderEditor(node); } function bumpWidget(node, name) { const widget = widgetByName(node, name); if (widget) { widget.value = Number(widget.value ?? 0) + 1; } } function setLoopSourceStatus(node, status) { node.ttpSmartTileLoopStatus = status; const widget = widgetByName(node, "loop_status"); if (widget) { widget.value = status; } scheduleCanvas(node); } function drawRoundedRect(ctx, x, y, width, height, radius) { if (ctx.roundRect) { ctx.roundRect(x, y, width, height, radius); return; } const r = Math.min(radius, width / 2, height / 2); ctx.moveTo(x + r, y); ctx.lineTo(x + width - r, y); ctx.quadraticCurveTo(x + width, y, x + width, y + r); ctx.lineTo(x + width, y + height - r); ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height); ctx.lineTo(x + r, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); } function highlightLoopStartButton(node) { const widget = widgetByName(node, "process_all_tiles"); if (!widget || widget.ttpSmartTileHighlighted) { return; } widget.value = "Start Loop / Process All Tiles"; widget.computeSize = (width) => [Math.max(300, Number(width) || 300), 48]; widget.draw = function (ctx, _node, widgetWidth, y, widgetHeight) { const width = Math.max(260, Number(widgetWidth) || 300); const height = Math.max(42, Number(widgetHeight) || 48); const x = 12; const buttonWidth = Math.max(180, width - 24); const buttonHeight = height - 8; const buttonY = y + 4; ctx.save(); ctx.beginPath(); drawRoundedRect(ctx, x, buttonY, buttonWidth, buttonHeight, 7); const gradient = ctx.createLinearGradient(x, buttonY, x + buttonWidth, buttonY + buttonHeight); gradient.addColorStop(0, "#38bdf8"); gradient.addColorStop(1, "#2563eb"); ctx.fillStyle = gradient; ctx.fill(); ctx.lineWidth = 1.5; ctx.strokeStyle = "#bae6fd"; ctx.stroke(); ctx.shadowColor = "rgba(56,189,248,.45)"; ctx.shadowBlur = 9; ctx.beginPath(); drawRoundedRect(ctx, x + 1, buttonY + 1, buttonWidth - 2, buttonHeight - 2, 6); ctx.strokeStyle = "rgba(255,255,255,.35)"; ctx.stroke(); ctx.shadowBlur = 0; ctx.fillStyle = "#f8fafc"; ctx.font = "bold 15px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(String(widget.value || "Start Loop"), x + buttonWidth / 2, buttonY + buttonHeight / 2); ctx.restore(); return true; }; widget.ttpSmartTileHighlighted = true; node.size = [Math.max(Number(node.size?.[0]) || 0, 360), Math.max(Number(node.size?.[1]) || 0, 160)]; resizeNode(node); } async function queueSmartTileLoop(node, restart = false) { if (!node) { return; } if (restart) { bumpWidget(node, "restart_request"); } bumpWidget(node, "loop_request"); node.ttpSmartTileLoopActive = true; setLoopSourceStatus(node, restart ? "Starting tile loop..." : "Queueing next tile..."); try { await app.queuePrompt(0); } catch (error) { node.ttpSmartTileLoopActive = false; setLoopSourceStatus(node, error?.message || "Could not queue tile loop."); } } function applyLoopEvent(detail) { if (!detail?.source_node_id || !app.graph) { return; } const node = app.graph.getNodeById?.(Number(detail.source_node_id)) ?? app.graph._nodes_by_id?.[detail.source_node_id]; if (!node || (node.comfyClass ?? node.type) !== LOOP_SOURCE_NAME || !node.ttpSmartTileLoopActive) { return; } const count = Number(detail.count ?? 0); const index = Number(detail.index ?? 0); if (detail.done) { node.ttpSmartTileLoopActive = false; setLoopSourceStatus(node, detail.message || `Done ${count}/${count}`); return; } setLoopSourceStatus(node, detail.message || `Next tile ${index + 1}/${count}`); queueSmartTileLoop(node, false); } function editorHeight(node) { return Math.max(360, editorStageSize(node).height + 156); } function editorStageSize(node) { const size = imageSourceSize(node); const availableWidth = Math.max(360, Math.round(Number(node.size?.[0] ?? 560) - 36)); const aspect = size ? Math.max(0.25, Math.min(4, size.height / size.width)) : 9 / 16; let width = availableWidth; let height = Math.max(220, Math.round(width * aspect)); const maxHeight = 720; if (height > maxHeight) { height = maxHeight; width = Math.max(220, Math.round(height / aspect)); } return { width, height }; } function ensureEditor(node) { if (node.ttpSmartTileContainer) { return node.ttpSmartTileContainer; } setWidgetVisible(widgetByName(node, "layout_json"), false); const container = document.createElement("div"); container.className = "ttp-smart-tile-editor"; container.tabIndex = 0; container.style.cssText = [ "box-sizing:border-box", "width:100%", "padding:8px", "border:1px solid rgba(140,148,160,.35)", "border-radius:6px", "background:#101827", "color:#e5e7eb", "font:12px/1.35 system-ui,-apple-system,BlinkMacSystemFont,sans-serif", "outline:none", ].join(";"); if (node.addDOMWidget) { const widget = node.addDOMWidget("smart_tile_editor", "Smart Tile Editor", container, { serialize: false, hideOnZoom: false, }); widget.computeSize = () => [Math.max(520, node.size?.[0] ?? 520), editorHeight(node)]; } else { node.addWidget("text", "smart_tile_editor", "Smart Tile Editor", () => {}, { serialize: false }); } node.ttpSmartTileContainer = container; return container; } function renderEditor(node) { const container = ensureEditor(node); const currentTiles = node.ttpSmartTileLayout?.tiles ?? parseLayout(node); const tiles = currentTiles.map((tile) => normalizeTile(node, tile)); const selectedIndex = Math.max(0, Math.min(tiles.length - 1, Number(node.ttpSmartTileSelectedIndex ?? 0))); const selectedIndexes = selectedTileIndexes(node, tiles.length, selectedIndex); const selectedSet = new Set(selectedIndexes); const hasTileMasks = tiles.some((tile) => Boolean(ownObjectMaskData(tile)?.data)); if (!hasTileMasks && maskOverlayEnabled(node)) { node.ttpSmartTileShowMasks = false; } if (!hasTileMasks && stitchMaskPreviewEnabled(node)) { node.ttpSmartTilePreviewStitchMask = false; } writeLayout(node, tiles, selectedIndex, selectedIndexes); const sourceSize = imageSourceSize(node); const selectedRect = tilePixelRect(node, tiles[selectedIndex]); const coverage = analyzeCoverage(tiles); container.replaceChildren(); const header = document.createElement("div"); header.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;"; const title = document.createElement("div"); title.textContent = "Smart Tile Image Editor"; title.style.cssText = "font-weight:700;font-size:13px;"; const value = document.createElement("div"); value.textContent = sourceSize ? `${sourceSize.width}x${sourceSize.height} / ${tiles.length} tiles` : `${tiles.length} tiles`; value.style.cssText = "opacity:.78;text-align:right;"; header.append(title, value); const stageSize = editorStageSize(node); const stage = document.createElement("div"); stage.style.cssText = [ "position:relative", `width:${stageSize.width}px`, "max-width:100%", `height:${stageSize.height}px`, "margin:0 auto", "border:1px solid rgba(226,232,240,.45)", "border-radius:6px", "overflow:hidden", "background:#172033", "touch-action:none", "cursor:crosshair", ].join(";"); if (node.ttpSmartTileImage?.element) { const image = node.ttpSmartTileImage.element.cloneNode(); image.draggable = false; image.style.cssText = [ "position:absolute", "inset:0", "width:100%", "height:100%", "object-fit:fill", "user-select:none", "pointer-events:none", ].join(";"); stage.append(image); } else { const empty = document.createElement("div"); empty.textContent = "Select or upload an image with the official image widget above."; empty.style.cssText = [ "position:absolute", "inset:0", "display:flex", "align-items:center", "justify-content:center", "color:rgba(226,232,240,.72)", "pointer-events:none", ].join(";"); stage.append(empty); } if (sourceSize && hasTileMasks && maskOverlayEnabled(node)) { const maskOverlay = document.createElement("canvas"); maskOverlay.title = "Tile object masks"; maskOverlay.style.cssText = [ "position:absolute", "inset:0", "width:100%", "height:100%", "pointer-events:none", "z-index:8", ].join(";"); stage.append(maskOverlay); renderMaskOverlay(node, maskOverlay, tiles).catch((error) => { node.ttpSmartTileStatus = error?.message || "Mask overlay failed."; scheduleCanvas(node); }); } else { node.ttpSmartTileMaskOverlayToken = null; } const paintCanvas = ensurePaintMaskCanvas(node); if (paintCanvas) { paintCanvas.style.cssText = [ "position:absolute", "inset:0", "width:100%", "height:100%", "opacity:.55", "mix-blend-mode:screen", "pointer-events:none", "z-index:70", ].join(";"); stage.append(paintCanvas); } for (const gap of coverage.gaps) { const overlay = document.createElement("div"); overlay.title = "Uncovered area"; overlay.style.cssText = [ "position:absolute", "box-sizing:border-box", `left:${gap.x0 * 100}%`, `top:${gap.y0 * 100}%`, `width:${Math.max(0.1, (gap.x1 - gap.x0) * 100)}%`, `height:${Math.max(0.1, (gap.y1 - gap.y0) * 100)}%`, "background:rgba(239,68,68,.28)", "border:1px dashed rgba(254,202,202,.85)", "pointer-events:none", ].join(";"); stage.append(overlay); } const pointFromEvent = (event) => { const rect = stage.getBoundingClientRect(); return { x: clampNumber((event.clientX - rect.left) / Math.max(1, rect.width), 0, 1), y: clampNumber((event.clientY - rect.top) / Math.max(1, rect.height), 0, 1), }; }; const paintMode = String(node.ttpSmartTilePaintMode || "off"); const brushSize = Math.max(4, Math.min(512, Math.round(Number(node.ttpSmartTileBrushSize ?? 64) || 64))); const paintAt = (event, lastPoint = null) => { const canvas = ensurePaintMaskCanvas(node); const context = canvas?.getContext("2d"); if (!canvas || !context) { return null; } const point = pointFromEvent(event); const x = point.x * canvas.width; const y = point.y * canvas.height; context.save(); context.globalCompositeOperation = paintMode === "erase" ? "destination-out" : "source-over"; context.lineCap = "round"; context.lineJoin = "round"; context.lineWidth = brushSize; context.strokeStyle = "rgba(125,211,252,.95)"; context.fillStyle = "rgba(125,211,252,.95)"; context.beginPath(); if (lastPoint) { context.moveTo(lastPoint.x * canvas.width, lastPoint.y * canvas.height); context.lineTo(x, y); context.stroke(); } else { context.arc(x, y, brushSize / 2, 0, Math.PI * 2); context.fill(); } context.restore(); syncPaintMaskWidget(node); scheduleCanvas(node); return point; }; stage.addEventListener("pointerdown", (event) => { if (paintMode !== "paint" && paintMode !== "erase") { return; } event.preventDefault(); event.stopPropagation(); let lastPoint = paintAt(event); const apply = (moveEvent) => { moveEvent.preventDefault(); moveEvent.stopPropagation(); lastPoint = paintAt(moveEvent, lastPoint); }; const cleanup = () => { window.removeEventListener("pointermove", apply, true); window.removeEventListener("pointerup", finish, true); window.removeEventListener("pointercancel", finish, true); window.removeEventListener("blur", finish, true); }; const finish = (endEvent) => { endEvent?.preventDefault?.(); endEvent?.stopPropagation?.(); cleanup(); renderEditor(node); }; window.addEventListener("pointermove", apply, true); window.addEventListener("pointerup", finish, true); window.addEventListener("pointercancel", finish, true); window.addEventListener("blur", finish, true); }, true); const tileContainsPoint = (tile, point) => ( point.x >= tile.x0 - 1e-6 && point.x <= tile.x1 + 1e-6 && point.y >= tile.y0 - 1e-6 && point.y <= tile.y1 + 1e-6 ); const resizeHit = (tile, point) => { const rect = stage.getBoundingClientRect(); const padX = Math.max(0.01, 18 / Math.max(1, rect.width)); const padY = Math.max(0.01, 18 / Math.max(1, rect.height)); return point.x >= tile.x1 - padX && point.x <= tile.x1 + padX && point.y >= tile.y1 - padY && point.y <= tile.y1 + padY; }; const setRegionStyle = (element, tile) => { element.style.left = `${tile.x0 * 100}%`; element.style.top = `${tile.y0 * 100}%`; element.style.width = `${Math.max(0.1, (tile.x1 - tile.x0) * 100)}%`; element.style.height = `${Math.max(0.1, (tile.y1 - tile.y0) * 100)}%`; }; const snapTargets = (axis, skipIndex) => { const values = [0, 0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1]; for (const [tileIndex, tile] of tiles.entries()) { if (tileIndex === skipIndex) { continue; } if (axis === "x") { values.push(tile.x0, tile.x1, (tile.x0 + tile.x1) / 2); } else { values.push(tile.y0, tile.y1, (tile.y0 + tile.y1) / 2); } } return [...new Set(values.map((value) => roundRatio(value)))].sort((a, b) => a - b); }; const nearestSnap = (value, axis, skipIndex) => { const rect = stage.getBoundingClientRect(); const dimension = axis === "x" ? rect.width : rect.height; const threshold = snapGuideThresholdPx / Math.max(1, dimension); let best = value; let bestDistance = threshold; for (const target of snapTargets(axis, skipIndex)) { const distance = Math.abs(value - target); if (distance <= bestDistance) { best = target; bestDistance = distance; } } return best; }; const snapMovedTile = (tile, skipIndex) => { const width = tile.x1 - tile.x0; const height = tile.y1 - tile.y0; const leftSnap = nearestSnap(tile.x0, "x", skipIndex); const rightSnap = nearestSnap(tile.x1, "x", skipIndex); const dx = Math.abs(leftSnap - tile.x0) <= Math.abs(rightSnap - tile.x1) ? leftSnap - tile.x0 : rightSnap - tile.x1; const topSnap = nearestSnap(tile.y0, "y", skipIndex); const bottomSnap = nearestSnap(tile.y1, "y", skipIndex); const dy = Math.abs(topSnap - tile.y0) <= Math.abs(bottomSnap - tile.y1) ? topSnap - tile.y0 : bottomSnap - tile.y1; return normalizeTile(node, { ...tile, x0: Math.max(0, Math.min(1 - width, tile.x0 + dx)), y0: Math.max(0, Math.min(1 - height, tile.y0 + dy)), x1: Math.max(width, Math.min(1, tile.x0 + dx + width)), y1: Math.max(height, Math.min(1, tile.y0 + dy + height)), }); }; const snapResizedTile = (tile, skipIndex) => normalizeTile(node, { ...tile, x1: nearestSnap(tile.x1, "x", skipIndex), y1: nearestSnap(tile.y1, "y", skipIndex), }); const snapCreatedTile = (tile, skipIndex) => normalizeTile(node, { ...tile, x0: nearestSnap(tile.x0, "x", skipIndex), y0: nearestSnap(tile.y0, "y", skipIndex), x1: nearestSnap(tile.x1, "x", skipIndex), y1: nearestSnap(tile.y1, "y", skipIndex), }); const renderOrder = tiles .map((_tile, index) => index) .filter((index) => !selectedSet.has(index)); renderOrder.push(...selectedIndexes.filter((index) => index !== selectedIndex)); renderOrder.push(selectedIndex); const regionElements = new Map(); const beginDrag = (event, tileIndex, mode, region) => { event.preventDefault(); event.stopPropagation(); const point = pointFromEvent(event); const selected = renderOrder.filter((index) => tileContainsPoint(tiles[index], point)).pop() ?? tileIndex; const index = Math.max(0, Math.min(tiles.length - 1, selected)); const dragMode = resizeHit(tiles[index], point) ? "resize" : mode; const targetRegion = regionElements.get(index) ?? region; if (boolEventModifier(event) && dragMode !== "resize") { setTileSelection(node, index, tiles.length, event); renderEditor(node); return; } setTileSelection(node, index, tiles.length); const startPoint = pointFromEvent(event); const startClientX = event.clientX; const startClientY = event.clientY; const startTiles = tiles.map((tile) => ({ ...tile })); const startTile = startTiles[index]; let hasDragged = false; const apply = (moveEvent) => { moveEvent.preventDefault(); moveEvent.stopPropagation(); const movedPx = Math.hypot(moveEvent.clientX - startClientX, moveEvent.clientY - startClientY); if (!hasDragged && movedPx < dragThresholdPx) { return; } if (!hasDragged) { hasDragged = true; if (targetRegion) { targetRegion.style.zIndex = "120"; } } const nextPoint = pointFromEvent(moveEvent); const dx = nextPoint.x - startPoint.x; const dy = nextPoint.y - startPoint.y; const nextTiles = startTiles.map((tile) => ({ ...tile })); if (dragMode === "resize") { nextTiles[index] = snapResizedTile(normalizeTile(node, { ...startTile, x1: startTile.x1 + dx, y1: startTile.y1 + dy, }), index); } else { const width = startTile.x1 - startTile.x0; const height = startTile.y1 - startTile.y0; const x0 = Math.max(0, Math.min(1 - width, startTile.x0 + dx)); const y0 = Math.max(0, Math.min(1 - height, startTile.y0 + dy)); nextTiles[index] = snapMovedTile(normalizeTile(node, { ...startTile, x0, y0, x1: x0 + width, y1: y0 + height }), index); } writeLayout(node, nextTiles, index); if (targetRegion) { setRegionStyle(targetRegion, nextTiles[index]); } scheduleCanvas(node); }; const cleanup = () => { window.removeEventListener("pointermove", apply, true); window.removeEventListener("pointerup", finish, true); window.removeEventListener("pointercancel", finish, true); window.removeEventListener("blur", finish, true); }; const finish = (endEvent) => { endEvent?.preventDefault?.(); endEvent?.stopPropagation?.(); cleanup(); renderEditor(node); }; window.addEventListener("pointermove", apply, true); window.addEventListener("pointerup", finish, true); window.addEventListener("pointercancel", finish, true); window.addEventListener("blur", finish, true); }; for (const index of renderOrder) { const tile = tiles[index]; const selected = selectedSet.has(index); const primarySelected = index === selectedIndex; const region = document.createElement("div"); region.textContent = String(index + 1); region.title = `Tile ${index + 1}${selected && !primarySelected ? " selected" : ""}`; region.style.cssText = [ "position:absolute", "box-sizing:border-box", "display:flex", "align-items:center", "justify-content:center", `left:${tile.x0 * 100}%`, `top:${tile.y0 * 100}%`, `width:${Math.max(0.1, (tile.x1 - tile.x0) * 100)}%`, `height:${Math.max(0.1, (tile.y1 - tile.y0) * 100)}%`, "border:" + (primarySelected ? "2px solid #f8fafc" : selected ? "2px solid #38bdf8" : "1px solid rgba(226,232,240,.55)"), "background:" + (primarySelected ? "rgba(14,165,233,.28)" : selected ? "rgba(14,165,233,.18)" : "rgba(15,23,42,.20)"), "color:#f8fafc", "font-weight:800", "letter-spacing:0", "box-shadow:" + (primarySelected ? "0 0 0 1px rgba(15,23,42,.85),0 0 16px rgba(14,165,233,.55)" : selected ? "0 0 0 1px rgba(15,23,42,.65)" : "none"), "z-index:" + (primarySelected ? "100" : selected ? "90" : String(10 + index)), "cursor:move", "user-select:none", "pointer-events:" + (paintMode === "off" ? "auto" : "none"), ].join(";"); regionElements.set(index, region); region.addEventListener("pointerdown", (event) => beginDrag(event, index, "move", region)); const handle = document.createElement("div"); handle.title = `Resize tile ${index + 1}`; handle.style.cssText = [ "position:absolute", "right:0", "bottom:0", "width:14px", "height:14px", "background:#f8fafc", "border-left:1px solid rgba(15,23,42,.8)", "border-top:1px solid rgba(15,23,42,.8)", "cursor:nwse-resize", ].join(";"); handle.addEventListener("pointerdown", (event) => beginDrag(event, index, "resize", region)); region.append(handle); stage.append(region); } stage.addEventListener("pointerdown", (event) => { if (event.target !== stage) { return; } event.preventDefault(); event.stopPropagation(); const point = pointFromEvent(event); const hits = renderOrder.filter((index) => tileContainsPoint(tiles[index], point)); if (hits.length || tiles.length >= MAX_TILES) { setTileSelection(node, hits.pop() ?? selectedIndex, tiles.length, event); renderEditor(node); return; } const startPoint = point; const startClientX = event.clientX; const startClientY = event.clientY; const newIndex = tiles.length; let hasDragged = false; let tempRegion = null; const apply = (moveEvent) => { moveEvent.preventDefault(); moveEvent.stopPropagation(); const movedPx = Math.hypot(moveEvent.clientX - startClientX, moveEvent.clientY - startClientY); if (!hasDragged && movedPx < dragThresholdPx) { return; } if (!hasDragged) { hasDragged = true; tempRegion = document.createElement("div"); tempRegion.textContent = String(newIndex + 1); tempRegion.style.cssText = [ "position:absolute", "box-sizing:border-box", "display:flex", "align-items:center", "justify-content:center", "border:2px solid #f8fafc", "background:rgba(14,165,233,.28)", "color:#f8fafc", "font-weight:800", "letter-spacing:0", "box-shadow:0 0 0 1px rgba(15,23,42,.85),0 0 16px rgba(14,165,233,.55)", "z-index:130", "pointer-events:none", ].join(";"); stage.append(tempRegion); } const nextPoint = pointFromEvent(moveEvent); const nextTiles = [...tiles, snapCreatedTile(normalizeTile(node, { x0: startPoint.x, y0: startPoint.y, x1: nextPoint.x, y1: nextPoint.y, }), newIndex)]; writeLayout(node, nextTiles, newIndex); setRegionStyle(tempRegion, nextTiles[newIndex]); scheduleCanvas(node); }; const cleanup = () => { window.removeEventListener("pointermove", apply, true); window.removeEventListener("pointerup", finish, true); window.removeEventListener("pointercancel", finish, true); window.removeEventListener("blur", finish, true); }; const finish = (endEvent) => { endEvent?.preventDefault?.(); endEvent?.stopPropagation?.(); cleanup(); renderEditor(node); }; window.addEventListener("pointermove", apply, true); window.addEventListener("pointerup", finish, true); window.addEventListener("pointercancel", finish, true); window.addEventListener("blur", finish, true); }); const controls = document.createElement("div"); controls.style.cssText = "display:flex;flex-direction:column;gap:8px;margin-top:8px;"; const actions = document.createElement("div"); actions.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;"; const gridControls = document.createElement("div"); gridControls.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;"; const gridLabel = document.createElement("span"); gridLabel.textContent = "Grid"; gridLabel.style.cssText = "opacity:.78;"; const gridColumns = createNumberInput(node.ttpSmartTileGridColumns ?? 2, 1, MAX_GRID_AXIS); const gridRows = createNumberInput(node.ttpSmartTileGridRows ?? 2, 1, MAX_GRID_AXIS); const gridMaskSelect = createSelect(gridMaskMode(node), [ { value: "off", label: "off" }, { value: "crop_mask", label: "crop" }, { value: "crop_mask_skip_empty", label: "skip empty" }, ]); const updateGridValues = () => { node.ttpSmartTileGridColumns = Math.max(1, Math.min(MAX_GRID_AXIS, Math.round(Number(gridColumns.value) || 1))); node.ttpSmartTileGridRows = Math.max(1, Math.min(MAX_GRID_AXIS, Math.round(Number(gridRows.value) || 1))); node.ttpSmartTileGridMaskMode = gridMaskSelect.value; gridColumns.value = String(node.ttpSmartTileGridColumns); gridRows.value = String(node.ttpSmartTileGridRows); gridMaskSelect.value = gridMaskMode(node); }; const refreshGridValues = () => { updateGridValues(); renderEditor(node); }; gridColumns.onchange = refreshGridValues; gridRows.onchange = refreshGridValues; gridMaskSelect.onchange = () => { node.ttpSmartTileGridMaskMode = gridMaskSelect.value; }; updateGridValues(); const gridCount = node.ttpSmartTileGridColumns * node.ttpSmartTileGridRows; const subdivideCount = tiles.length - 1 + gridCount; gridControls.append( gridLabel, gridColumns, document.createTextNode("x"), gridRows, document.createTextNode("Mask"), gridMaskSelect, createButton("Replace grid", () => { updateGridValues(); const nextTiles = gridTiles(node, node.ttpSmartTileGridColumns, node.ttpSmartTileGridRows); node.ttpSmartTileStatus = ""; writeLayout(node, nextTiles, 0); renderEditor(node); }, gridCount > MAX_TILES), createButton(`Grid in T${selectedIndex + 1}`, async () => { updateGridValues(); const selectedTile = tiles[selectedIndex] ?? { x0: 0, y0: 0, x1: 1, y1: 1 }; const maskMode = gridMaskMode(node); const replacementInfo = await gridTilesWithInheritedMask( node, selectedTile, node.ttpSmartTileGridColumns, node.ttpSmartTileGridRows, maskMode ).catch((error) => { node.ttpSmartTileStatus = error?.message || "Grid mask split failed."; return null; }); if (!replacementInfo) { renderEditor(node); return; } const replacement = replacementInfo.tiles; if (!replacement.length) { node.ttpSmartTileStatus = "Grid mask split skipped every empty tile."; renderEditor(node); return; } if (tiles.length - 1 + replacement.length > MAX_TILES) { node.ttpSmartTileStatus = `Grid would create ${tiles.length - 1 + replacement.length} tiles; max is ${MAX_TILES}.`; renderEditor(node); return; } const nextTiles = [ ...tiles.slice(0, selectedIndex), ...replacement, ...tiles.slice(selectedIndex + 1), ]; const maskStatus = replacementInfo.cropped ? ` / mask crops ${replacementInfo.cropped}${replacementInfo.skipped ? `, skipped ${replacementInfo.skipped}` : ""}${replacementInfo.emptied ? `, empty ${replacementInfo.emptied}` : ""}` : ""; node.ttpSmartTileStatus = maskStatus ? `Grid split${maskStatus}` : ""; writeLayout(node, nextTiles, selectedIndex); renderEditor(node); }, subdivideCount > MAX_TILES) ); controls.append(gridControls); const paintControls = document.createElement("div"); paintControls.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;"; const paintLabel = document.createElement("span"); paintLabel.textContent = "Mask"; paintLabel.style.cssText = "opacity:.78;"; const brushInput = createNumberInput(node.ttpSmartTileBrushSize ?? 64, 4, 512); brushInput.title = "Brush size in source pixels"; brushInput.onchange = () => { node.ttpSmartTileBrushSize = Math.max(4, Math.min(512, Math.round(Number(brushInput.value) || 64))); renderEditor(node); }; const paintButton = createButton("Brush", () => { node.ttpSmartTilePaintMode = node.ttpSmartTilePaintMode === "paint" ? "off" : "paint"; renderEditor(node); }); const eraseButton = createButton("Erase", () => { node.ttpSmartTilePaintMode = node.ttpSmartTilePaintMode === "erase" ? "off" : "erase"; renderEditor(node); }); const moveButton = createButton("Move", () => { node.ttpSmartTilePaintMode = "off"; renderEditor(node); }); moveButton.title = "Return to tile move and resize mode"; if (paintMode === "off") { moveButton.style.background = "#a7f3d0"; moveButton.style.borderColor = "#34d399"; moveButton.style.color = "#064e3b"; } if (paintMode === "paint") { paintButton.style.background = "#38bdf8"; paintButton.style.borderColor = "#7dd3fc"; paintButton.style.color = "#082f49"; } if (paintMode === "erase") { eraseButton.style.background = "#f8fafc"; eraseButton.style.borderColor = "#e2e8f0"; eraseButton.style.color = "#0f172a"; } const maskOverlayButton = createButton(maskOverlayEnabled(node) ? "Hide masks" : "Show masks", () => { node.ttpSmartTileShowMasks = !maskOverlayEnabled(node); renderEditor(node); }, !hasTileMasks); maskOverlayButton.title = "Toggle colored object-mask overlay"; if (maskOverlayEnabled(node)) { maskOverlayButton.style.background = "#a7f3d0"; maskOverlayButton.style.borderColor = "#34d399"; maskOverlayButton.style.color = "#064e3b"; } const stitchMaskButton = createButton(stitchMaskPreviewEnabled(node) ? "Raw masks" : "Stitch mask", () => { node.ttpSmartTilePreviewStitchMask = !stitchMaskPreviewEnabled(node); if (node.ttpSmartTilePreviewStitchMask) { node.ttpSmartTileShowMasks = true; } renderEditor(node); }, !hasTileMasks); stitchMaskButton.title = "Preview the hardened and feathered mask shape used by replace pasteback"; if (stitchMaskPreviewEnabled(node)) { stitchMaskButton.style.background = "#fde68a"; stitchMaskButton.style.borderColor = "#f59e0b"; stitchMaskButton.style.color = "#78350f"; } paintControls.append( paintLabel, moveButton, maskOverlayButton, stitchMaskButton, paintButton, eraseButton, document.createTextNode("px"), brushInput, createButton("Mask to Tile", () => { if (addPaintMaskTiles(node, tiles, selectedIndex)) { renderEditor(node); } else { 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) => { node.ttpSmartTileStatus = error?.message || "Refresh masks failed."; return null; }); if (!refreshed) { renderEditor(node); return; } if (!refreshed.refreshed && !refreshed.skipped && !refreshed.emptied) { node.ttpSmartTileStatus = "No inherited masks to refresh."; renderEditor(node); return; } if (!refreshed.tiles.length) { node.ttpSmartTileStatus = "Refresh masks skipped every empty mask tile."; renderEditor(node); return; } const parts = [`refreshed ${refreshed.refreshed}`]; if (refreshed.emptied) { parts.push(`cleared ${refreshed.emptied}`); } if (refreshed.skipped) { parts.push(`skipped ${refreshed.skipped}`); } node.ttpSmartTileStatus = `Refresh masks ${parts.join(", ")} tile(s).`; writeLayout(node, refreshed.tiles, Math.min(selectedIndex, refreshed.tiles.length - 1)); renderEditor(node); }, !tiles.some((tile) => tile?.object_mask_source?.data)), createButton("Merge masks", async () => { await mergeSelectedMasks(node, tiles, selectedIndexes, selectedIndex).catch((error) => { node.ttpSmartTileStatus = error?.message || "Merge masks failed."; return false; }); renderEditor(node); }, selectedIndexes.length < 2), createButton("Clear mask", () => { clearPaintMask(node); node.ttpSmartTilePaintMode = "off"; renderEditor(node); }, !paintMaskHasPixels(ensurePaintMaskCanvas(node))) ); controls.append(paintControls); for (const [index] of tiles.entries()) { const button = createButton(`T${index + 1}`, (event) => { setTileSelection(node, index, tiles.length, event); renderEditor(node); }); if (selectedSet.has(index)) { button.style.background = index === selectedIndex ? "#38bdf8" : "#bae6fd"; button.style.borderColor = index === selectedIndex ? "#7dd3fc" : "#38bdf8"; button.style.color = "#082f49"; } actions.append(button); } actions.append( createButton("Auto Tile", () => { syncPaintMaskWidget(node); inferSmartTileLayout(node); }), createButton("Auto SAM", () => { syncPaintMaskWidget(node); inferSmartTileLayout(node, { fillGaps: false, label: "Auto SAM", modeOverride: "sam3.1", }); }), createButton("Add tile", () => { const base = tiles[selectedIndex] ?? { x0: 0.25, y0: 0.25, x1: 0.75, y1: 0.75 }; const width = Math.max(0.18, Math.min(0.48, base.x1 - base.x0)); const height = Math.max(0.18, Math.min(0.48, base.y1 - base.y0)); const offset = Math.min(0.14, 0.03 * tiles.length); const x0 = Math.min(1 - width, Math.max(0, base.x0 + offset)); const y0 = Math.min(1 - height, Math.max(0, base.y0 + offset)); writeLayout(node, [...tiles, { x0, y0, x1: x0 + width, y1: y0 + height, source: "manual", label: "manual focus", priority: 120, importance: 1.0, layer: 5, occlusion_priority: 3000 + tiles.length, }], tiles.length); renderEditor(node); }, tiles.length >= MAX_TILES), createButton("Delete", () => { writeLayout(node, tiles.filter((_tile, index) => index !== selectedIndex), Math.max(0, selectedIndex - 1)); renderEditor(node); }, tiles.length <= 1), createButton("Merge tiles", async () => { await mergeSelectedTiles(node, tiles, selectedIndexes, selectedIndex).catch((error) => { node.ttpSmartTileStatus = error?.message || "Merge tiles failed."; return false; }); renderEditor(node); }, selectedIndexes.length < 2), createButton("Fill gaps", () => { const filled = fillTileGaps(node, tiles); node.ttpSmartTileStatus = filled.coverage.gaps.length ? `${filled.coverage.gaps.length} gap(s) remain.` : ""; writeLayout(node, filled.tiles, Math.min(tiles.length, filled.tiles.length - 1)); renderEditor(node); }, !coverage.gaps.length || tiles.length >= MAX_TILES), createButton("Reset 2x2", () => { writeLayout(node, defaultTiles(node), 0); renderEditor(node); }) ); controls.append(actions); const status = document.createElement("div"); status.style.cssText = "display:flex;gap:10px;flex-wrap:wrap;opacity:.76;"; const selectedLabel = selectedRect ? selectedIndexes.length > 1 ? `${selectedIndexes.map((index) => `T${index + 1}`).join(",")} selected / active T${selectedIndex + 1}` : `T${selectedIndex + 1}: x${selectedRect.x0}, y${selectedRect.y0}, ${selectedRect.width}x${selectedRect.height}` : `T${selectedIndex + 1}`; const coverageLabel = coverage.gaps.length ? `${coverage.gaps.length} gap(s), ${(coverage.uncoveredArea * 100).toFixed(2)}%` : "covered"; const extra = node.ttpSmartTileStatus ? ` / ${node.ttpSmartTileStatus}` : ""; status.textContent = `${selectedLabel} / ${coverageLabel} / snap ${snapStep(node)} px / max ${MAX_TILES}${extra}`; controls.append(status); container.append(header, stage, controls); resizeNode(node); } function attachWidgetRefresh(node) { const autoRequestWidget = widgetByName(node, "auto_detect_request"); setWidgetVisible(autoRequestWidget, false); if (autoRequestWidget && Number(autoRequestWidget.value ?? 0) > 0) { autoRequestWidget.value = 0; } setWidgetVisible(widgetByName(node, "auto_paint_mask"), false); const imageWidget = widgetByName(node, "image"); if (imageWidget && !imageWidget.ttpSmartTileWrapped) { const original = imageWidget.callback; imageWidget.callback = function () { original?.apply(this, arguments); loadSelectedInputImage(node, true); }; imageWidget.ttpSmartTileWrapped = true; } for (const name of ["default_pad", "default_blend", "round_to"]) { const widget = widgetByName(node, name); if (!widget || widget.ttpSmartTileWrapped) { continue; } const original = widget.callback; widget.callback = function () { original?.apply(this, arguments); writeLayout(node, node.ttpSmartTileLayout?.tiles ?? parseLayout(node), node.ttpSmartTileSelectedIndex ?? 0); renderEditor(node); }; widget.ttpSmartTileWrapped = true; } } api.addEventListener("ttp-smart-tile-layout", (event) => { applyInferenceResult(event.detail); }); api.addEventListener("ttp-smart-tile-loop", (event) => { applyLoopEvent(event.detail); }); app.registerExtension({ name: "ttp.smart_tile.interactive_crop", beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name === LOOP_SOURCE_NAME) { const originalOnNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { originalOnNodeCreated?.apply(this, arguments); setWidgetVisible(widgetByName(this, "restart_request"), false); setWidgetVisible(widgetByName(this, "loop_request"), false); if (!widgetByName(this, "process_all_tiles")) { this.addWidget("button", "process_all_tiles", "Process All Tiles", () => { queueSmartTileLoop(this, true); }, { serialize: false }); } highlightLoopStartButton(this); if (!widgetByName(this, "stop_tile_loop")) { this.addWidget("button", "stop_tile_loop", "Stop Tile Loop", () => { this.ttpSmartTileLoopActive = false; setLoopSourceStatus(this, "Stopped."); }, { serialize: false }); } if (!widgetByName(this, "loop_status")) { this.addWidget("text", "loop_status", this.ttpSmartTileLoopStatus || "Idle.", () => {}, { serialize: false }); } }; const originalOnConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { originalOnConfigure?.apply(this, arguments); requestAnimationFrame(() => { setWidgetVisible(widgetByName(this, "restart_request"), false); setWidgetVisible(widgetByName(this, "loop_request"), false); highlightLoopStartButton(this); }); }; return; } if (nodeData.name !== NODE_NAME) { return; } const originalOnNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { originalOnNodeCreated?.apply(this, arguments); ensureEditor(this); attachWidgetRefresh(this); writeLayout(this, parseLayout(this), this.ttpSmartTileSelectedIndex ?? 0); loadSelectedInputImage(this); renderEditor(this); }; const originalOnConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { originalOnConfigure?.apply(this, arguments); requestAnimationFrame(() => { ensureEditor(this); attachWidgetRefresh(this); loadSelectedInputImage(this); writeLayout(this, parseLayout(this), this.ttpSmartTileSelectedIndex ?? 0); renderEditor(this); }); }; }, });