Files
TTPlanetPig-Comfyui_TTP_Too…/web/js/ttp_smart_tile_interactive.js
T

1624 lines
60 KiB
JavaScript

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 STORAGE_PREFIX = "ttp_smart_tile_interactive_layout";
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 ["name", "source", "label", "score", "layer", "object_id", "occlusion_priority", "priority", "importance", "pad", "blend", "object_mask"]) {
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 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 maskCropData(canvas, box) {
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");
context.drawImage(canvas, box.x0, box.y0, width, height, 0, 0, width, height);
return crop.toDataURL("image/png").split(",", 2)[1];
}
function addPaintMaskTiles(node, tiles, selectedIndex) {
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 defaults = layoutDefaults(node);
const nextTiles = [...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: {
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),
},
}));
added += 1;
}
if (!added) {
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)));
clearPaintMask(node);
node.ttpSmartTilePaintMode = "off";
node.ttpSmartTileStatus = `Mask to Tile added ${added} tile(s).`;
return true;
}
function writeLayout(node, tiles, selectedIndex = 0) {
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)));
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 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 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) {
const mode = String(widgetByName(node, "auto_detect_mode")?.value ?? "none");
if (mode === "none") {
node.ttpSmartTileStatus = "Set auto detect mode to sam3.1 or qwenvl3 before inference.";
renderEditor(node);
return;
}
const requestWidget = widgetByName(node, "auto_detect_request");
if (requestWidget) {
requestWidget.value = Number(requestWidget.value ?? 0) + 1;
}
node.ttpSmartTileStatus = `Queued ${mode} inference...`;
renderEditor(node);
try {
await app.queuePrompt(0);
} 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 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) {
const filled = fillTileGaps(node, layout.tiles);
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.`);
}
}
} 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)));
writeLayout(node, tiles, selectedIndex);
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);
}
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) => 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;
node.ttpSmartTileSelectedIndex = index;
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 = index === selectedIndex;
const region = document.createElement("div");
region.textContent = String(index + 1);
region.title = `Tile ${index + 1}`;
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:" + (selected ? "2px solid #f8fafc" : "1px solid rgba(226,232,240,.55)"),
"background:" + (selected ? "rgba(14,165,233,.28)" : "rgba(15,23,42,.20)"),
"color:#f8fafc",
"font-weight:800",
"letter-spacing:0",
"box-shadow:" + (selected ? "0 0 0 1px rgba(15,23,42,.85),0 0 16px rgba(14,165,233,.55)" : "none"),
"z-index:" + (selected ? "100" : 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) {
node.ttpSmartTileSelectedIndex = hits.pop() ?? selectedIndex;
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 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)));
gridColumns.value = String(node.ttpSmartTileGridColumns);
gridRows.value = String(node.ttpSmartTileGridRows);
};
const refreshGridValues = () => {
updateGridValues();
renderEditor(node);
};
gridColumns.onchange = refreshGridValues;
gridRows.onchange = refreshGridValues;
updateGridValues();
const gridCount = node.ttpSmartTileGridColumns * node.ttpSmartTileGridRows;
const subdivideCount = tiles.length - 1 + gridCount;
gridControls.append(
gridLabel,
gridColumns,
document.createTextNode("x"),
gridRows,
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}`, () => {
updateGridValues();
const selectedTile = tiles[selectedIndex] ?? { x0: 0, y0: 0, x1: 1, y1: 1 };
const replacement = gridTiles(node, node.ttpSmartTileGridColumns, node.ttpSmartTileGridRows, selectedTile);
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),
];
node.ttpSmartTileStatus = "";
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);
});
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";
}
paintControls.append(
paintLabel,
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("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}`, () => {
node.ttpSmartTileSelectedIndex = index;
renderEditor(node);
});
if (index === selectedIndex) {
button.style.background = "#38bdf8";
button.style.borderColor = "#7dd3fc";
button.style.color = "#082f49";
}
actions.append(button);
}
actions.append(
createButton("Auto Tile", () => {
syncPaintMaskWidget(node);
inferSmartTileLayout(node);
}),
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("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
? `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);
});
};
},
});