Files
malkuthro-ComfyUI-Koolook/web/ai_pipeline.js
malkuthro 83b170bdae feat(setups): add publish contract nodes (#228)
* feat(setups): infer app contract from named fields

* feat(setups): add publish contract nodes

* feat(setups): split publish result contract

* fix(setups): resolve publish output reroutes in preview

* fix(setups): validate app surface on read

* fix(setups): harden app contract validation
2026-06-07 22:02:16 +02:00

451 lines
25 KiB
JavaScript

// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
// SPDX-License-Identifier: GPL-3.0-only
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
import { bulletproofStringWidget } from "./widget_utils.js";
globalThis.__KOLOOK_AI_PIPELINE_PREVIEW_RESOLVER__ = "easyuse-getset-v2";
app.registerExtension({
name: "koolook.ai_pipeline",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === "EasyAIPipeline") {
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (originalOnNodeCreated) {
originalOnNodeCreated.call(this);
}
// Guard the version STRING widget. Pre-PR#180 workflows save
// an INT here; widget-to-input conversion can void the value
// to undefined. Either trips pysssss's .replace callback.
bulletproofStringWidget(this.widgets.find(w => w.name === "version"), "v001");
// Make instruction widget read-only and dimmed
const instructionWidget = this.widgets.find(w => w.name === "instruction");
if (instructionWidget && instructionWidget.inputEl) {
instructionWidget.inputEl.readOnly = true;
instructionWidget.inputEl.style.opacity = 0.6;
}
// Standard ComfyUI multi-line STRING widget. Word-wrap is the default
// and intentional — long paths visually wrap to multiple rows so the user
// can mouse-drag-select the whole string and copy it. The wrap is purely
// visual; the underlying string is one logical line because cleanLine
// strips any actual newlines from upstream inputs before the path build.
const displayWidget = ComfyWidgets["STRING"](this, "Output Preview", ["STRING", { multiline: true }], app).widget;
displayWidget.inputEl.readOnly = true;
displayWidget.inputEl.style.height = "100px";
// Defensive strip: an upstream node might feed shot_name / ai_method with
// embedded newlines, tabs, or carriage returns (e.g. a Text Multiline node
// with a stray paragraph break). Those would silently break filesystem
// writes — the path string can't legally contain them. Strip before display
// so the preview matches what the Python node will actually write.
const cleanLine = (s) => String(s ?? "").replace(/[\r\n\t]+/g, "");
const SENTINEL_STRINGS = new Set(["undefined", "null", "none"]);
// Mirror of _normalize_text_input in k_ai_pipeline.py.
const normalizeTextInput = (raw) => {
let s = cleanLine(raw).trim();
if (s.length >= 2 && s[0] === s[s.length - 1] && (s[0] === '"' || s[0] === "'")) {
s = s.slice(1, -1).trim();
}
if (SENTINEL_STRINGS.has(s.toLowerCase())) {
return "";
}
return s;
};
// Strict: extension must never contain whitespace at all. Even a single
// trailing space (easy to acquire from a paste) makes downstream save
// nodes that check via os.path.splitext fail with "filepath doesn't end
// in .exr" — splitext returns ".exr " with the space, which doesn't match.
const cleanExtension = (s) => String(s ?? "").replace(/\s/g, "");
const resolveVersionToken = (version, disableVersioning) => {
if (disableVersioning) return "";
const token = normalizeTextInput(version).replace(/[\/\\]/g, "_");
if (!token) return "";
if (/^\d+$/.test(token)) {
return `v${String(Number.parseInt(token, 10)).padStart(3, "0")}`;
}
return token;
};
const getNodeWidgetValue = (node, slot) => {
const output = node.outputs?.[slot];
const candidateWidgets = [
node.widgets?.[slot],
output?.name ? node.widgets?.find(w => w.name === output.name) : null,
node.widgets?.find(w => w.name === "value"),
node.widgets?.find(w => w.name === "text"),
node.widgets?.find(w => w.name === "string"),
].filter(Boolean);
for (const widget of candidateWidgets) {
if (widget && "value" in widget) {
return widget.value;
}
}
if (Array.isArray(node.widgets_values) && node.widgets_values.length > 0) {
return node.widgets_values[slot] ?? node.widgets_values[0];
}
return null;
};
const getWidgetValueByName = (node, name, fallback = null) => {
const widget = node?.widgets?.find(w => w.name === name);
if (widget && "value" in widget) return widget.value;
if (Array.isArray(node?.widgets_values)) {
const inputIndex = node.inputs?.findIndex(input => input.name === name);
if (inputIndex >= 0 && node.widgets_values[inputIndex] !== undefined) {
return node.widgets_values[inputIndex];
}
}
return fallback;
};
const getLink = (graph, linkId) => {
if (!graph || linkId === null || linkId === undefined) return null;
if (typeof graph.getLink === "function") return graph.getLink(linkId);
const links = graph.links ?? graph._links;
if (links instanceof Map) return links.get(linkId) ?? null;
return links?.[linkId] ?? null;
};
const getGraphNodeById = (graph, nodeId) => {
if (!graph || nodeId === null || nodeId === undefined) return null;
if (typeof graph.getNodeById === "function") return graph.getNodeById(nodeId);
return graph._nodes?.find(n => n.id === nodeId) ?? null;
};
const linkOriginId = (link) => link?.origin_id ?? link?.[1];
const linkOriginSlot = (link) => link?.origin_slot ?? link?.[2];
const graphIds = new WeakMap();
let nextGraphId = 1;
const graphSeenKey = (graph, linkId) => {
if (!graph || (typeof graph !== "object" && typeof graph !== "function")) {
return `graph:${linkId}`;
}
if (!graphIds.has(graph)) {
graphIds.set(graph, nextGraphId++);
}
return `${graphIds.get(graph)}:${linkId}`;
};
const isEasyUseGetNode = (node) => {
return node?.type === "easy getNode"
|| node?.comfyClass === "easy getNode"
|| (String(node?.title ?? "").startsWith("Get_") && node?.widgets?.[0]?.name === "Constant");
};
const findEasyUseSetter = (getNode, graph) => {
if (typeof getNode.findSetter === "function") {
const setter = getNode.findSetter(graph || getNode.graph || app.graph);
if (setter) return setter;
}
const key = getNode.widgets?.[0]?.value;
if (!key) return null;
return (graph || getNode.graph || app.graph)?._nodes?.find(otherNode => {
return (otherNode.type === "easy setNode"
|| otherNode.comfyClass === "easy setNode"
|| String(otherNode.title ?? "").startsWith("Set_"))
&& otherNode.widgets?.[0]?.value === key;
}) ?? null;
};
const isRerouteNode = (node) => {
const type = node?.type ?? node?.comfyClass;
return type === "Reroute" || type === "PrimitiveNode";
};
const resolveSubgraphInputValue = (hostNode, inputSlot, seen) => {
if (!hostNode) return null;
const hostInput = hostNode.inputs?.[inputSlot];
if (!hostInput) return null;
if (hostInput.link !== null && hostInput.link !== undefined) {
return resolveLinkValue(hostInput.link, null, seen, hostNode.graph || app.graph, null);
}
const widgetName = hostInput.widget?.name ?? hostInput.name;
return getWidgetValueByName(hostNode, widgetName, getNodeWidgetValue(hostNode, inputSlot));
};
const resolveSubgraphOutputValue = (hostNode, outputSlot, seen) => {
const subgraph = hostNode?.subgraph;
const output = subgraph?.outputs?.[outputSlot];
const linkIds = output?.linkIds ?? output?.links;
const innerLinkId = Array.isArray(linkIds) ? linkIds[0] : linkIds;
if (innerLinkId === null || innerLinkId === undefined) return null;
return resolveLinkValue(innerLinkId, null, seen, subgraph, hostNode);
};
const evaluateTextConcatenate = (node, graph, seen, subgraphHost) => {
// WAS-NodeSuite currently serializes delimiter, clean-whitespace
// in that widget order. If upstream reorders, prefer named widgets.
const delimiter = getWidgetValueByName(node, "delimiter", node.widgets_values?.[0] ?? "");
const cleanWhitespace = getWidgetValueByName(node, "clean_whitespace", node.widgets_values?.[1] ?? true);
const parts = [];
for (const input of node.inputs ?? []) {
if (!/^text_[a-z]$/i.test(input.name ?? "")) continue;
let value = null;
if (input.link !== null && input.link !== undefined) {
value = resolveLinkValue(input.link, null, seen, graph, subgraphHost);
}
value = normalizeTextInput(value);
if (cleanWhitespace === true || cleanWhitespace === "true") {
value = value.trim();
}
if (value) parts.push(value);
}
return parts.join(String(delimiter ?? ""));
};
const evaluateKnownNodeOutput = (node, slot, graph, seen, subgraphHost) => {
if (slot !== 0) return null;
if (node?.type === "Text Multiline") {
return getWidgetValueByName(node, "text", node.widgets_values?.[0] ?? "");
}
if (node?.type === "Text Concatenate") {
return evaluateTextConcatenate(node, graph, seen, subgraphHost);
}
if (node?.type === "Easy_Utility") {
const mode = getWidgetValueByName(node, "mode", node.widgets_values?.[0] ?? "int_to_padded_string");
if (mode !== "int_to_padded_string") return null;
const intInput = node.inputs?.find(input => input.name === "int_value");
let intValue;
if (intInput?.link !== null && intInput?.link !== undefined) {
intValue = resolveLinkValue(intInput.link, null, seen, graph, subgraphHost);
}
if (intValue === null || intValue === undefined) {
intValue = getWidgetValueByName(node, "int_value", node.widgets_values?.[1] ?? 1);
}
const prefix = getWidgetValueByName(node, "prefix", node.widgets_values?.[2] ?? "");
const padWidth = getWidgetValueByName(node, "pad_width", node.widgets_values?.[3] ?? 3);
const n = Number.parseInt(intValue, 10);
const w = Math.max(0, Number.parseInt(padWidth, 10) || 0);
if (Number.isNaN(n)) return null;
return `${prefix ?? ""}${String(n).padStart(w, "0")}`;
}
return null;
};
const resolveLinkValue = (linkId, fallback, seen = new Set(), graph = app.graph, subgraphHost = null) => {
const seenKey = graphSeenKey(graph, linkId);
if (linkId === null || linkId === undefined || seen.has(seenKey)) {
return fallback;
}
seen.add(seenKey);
const link = getLink(graph, linkId);
if (!link) return fallback;
const originId = linkOriginId(link);
const originSlot = linkOriginSlot(link);
// LiteGraph uses -10 for the subgraph input boundary node.
if (originId === -10) {
const value = resolveSubgraphInputValue(subgraphHost, originSlot, seen);
return value ?? fallback;
}
const originNode = getGraphNodeById(graph, originId);
if (!originNode) return fallback;
if (isRerouteNode(originNode)) {
const upstreamLink = originNode.inputs?.[originSlot]?.link
?? originNode.inputs?.[0]?.link;
const value = resolveLinkValue(upstreamLink, null, seen, graph, subgraphHost);
return value ?? fallback;
}
const subgraphValue = resolveSubgraphOutputValue(originNode, originSlot, seen);
if (subgraphValue !== null && subgraphValue !== undefined) return subgraphValue;
const knownValue = evaluateKnownNodeOutput(originNode, originSlot, graph, seen, subgraphHost);
if (knownValue !== null && knownValue !== undefined) return knownValue;
// EasyUse GET nodes are virtual tunnels. The visible widget is the
// key name ("OUT-folder"), not the value. Follow GET -> matching SET
// -> SET input link so preview buttons mirror render-time execution.
if (isEasyUseGetNode(originNode)) {
const setter = findEasyUseSetter(originNode, graph);
const setterLink = setter?.inputs?.[0]?.link;
if (setterLink !== null && setterLink !== undefined) {
const resolved = resolveLinkValue(setterLink, null, seen, graph, subgraphHost);
if (resolved !== null && resolved !== undefined) return resolved;
}
}
return getNodeWidgetValue(originNode, originSlot);
};
// Helper function to get effective value (from widget or upstream if connected and simple)
const getEffectiveValue = (node, name) => {
const widget = node.widgets.find(w => w.name === name);
const input = node.inputs?.find(i => i.name === name);
if (!input || input.link === null) {
return widget?.value;
}
return resolveLinkValue(input.link, null);
};
// Mirror of _normalize_base_path in k_ai_pipeline.py — must stay in sync.
// Strips control chars (newlines/tabs from upstream text widgets), then
// surrounding whitespace, one pair of surrounding quotes, and trailing
// path separators. Drive roots (C:\, n:/) are preserved.
const normalizeBasePath = (raw) => {
let s = normalizeTextInput(raw);
while (s.length > 3 && (s[s.length - 1] === "/" || s[s.length - 1] === "\\")) {
s = s.slice(0, -1);
}
return stripSentinelComponents(s);
};
const stripSentinelComponents = (path) => {
if (!path) return path;
const sep = path.includes("\\") ? "\\" : "/";
const cleaned = path
.split(sep)
.filter(part => part === "" || !SENTINEL_STRINGS.has(part.toLowerCase()));
return cleaned.join(sep);
};
// Mirror of _sanitize_segment in k_ai_pipeline.py — strips control chars
// and surrounding whitespace first, then drive prefix and leading
// separators so a segment can only be joined onto base_directory_path,
// never replace it via os.path.join's "last absolute path wins" semantics.
// Leading seps are stripped BEFORE splitdrive so multi-slash input
// doesn't get parsed as a UNC prefix (matches Python's flow).
const sanitizeSegment = (raw) => {
let s = normalizeTextInput(raw);
s = s.replace(/^[\/\\]+/, "");
if (/^[a-zA-Z]:/.test(s)) s = s.slice(2);
return s.replace(/^[\/\\]+/, "");
};
// Mirror of the Python directory build in k_ai_pipeline.py — must stay in sync.
// With no_subfolders=true: only [base, version_str] go into the directory;
// shot_name and ai_method drop out (they're only in the filename below).
// The version folder still applies when versioning is enabled. With
// no_subfolders=false: full [base, shot, ai, version] chain.
const buildOutputDirectory = (values, version_str) => {
const base = normalizeBasePath(values.base_directory_path);
const shotSeg = sanitizeSegment(values.shot_name);
const aiSeg = sanitizeSegment(values.ai_method);
let dir;
if (values.no_subfolders) {
dir = [base, version_str]
.filter(part => part.toString().trim() !== "")
.join("/");
} else {
dir = [base, shotSeg, aiSeg, version_str]
.filter(part => part.toString().trim() !== "")
.join("/");
}
dir = dir.replace(/\\/g, "/").replace(/\/+/g, '/');
dir = stripSentinelComponents(dir);
// Strip trailing slash unless we'd be left with a bare drive root like 'n:/'.
if (dir.length > 3) {
dir = dir.replace(/\/+$/, '');
}
return dir;
};
this.addWidget("button", "Get output directory path", null, () => {
const values = {
base_directory_path: getEffectiveValue(this, "base_directory_path"),
shot_name: getEffectiveValue(this, "shot_name"),
ai_method: getEffectiveValue(this, "ai_method"),
version: getEffectiveValue(this, "version"),
disable_versioning: getEffectiveValue(this, "disable_versioning"),
enable_overwrite: getEffectiveValue(this, "enable_overwrite"),
no_subfolders: getEffectiveValue(this, "no_subfolders"),
};
if (Object.values(values).some(v => v === null)) {
displayWidget.value = "Cannot preview: complex inputs - execute node for accurate path.";
} else {
const version_str = resolveVersionToken(values.version, values.disable_versioning);
displayWidget.value = buildOutputDirectory(values, version_str);
}
this.setDirtyCanvas(true, true);
}, { serialize: false });
this.addWidget("button", "Get output file path", null, () => {
const values = {
base_directory_path: getEffectiveValue(this, "base_directory_path"),
shot_name: getEffectiveValue(this, "shot_name"),
ai_method: getEffectiveValue(this, "ai_method"),
extension: getEffectiveValue(this, "extension"),
version: getEffectiveValue(this, "version"),
disable_versioning: getEffectiveValue(this, "disable_versioning"),
enable_overwrite: getEffectiveValue(this, "enable_overwrite"),
no_subfolders: getEffectiveValue(this, "no_subfolders"),
};
if (Object.values(values).some(v => v === null)) {
displayWidget.value = "Cannot preview: complex inputs - execute node for accurate path.";
} else {
const version_str = resolveVersionToken(values.version, values.disable_versioning);
const output_directory = buildOutputDirectory(values, version_str);
// Filenames can't contain / or \ on any OS, so flatten any internal
// separators in shot_name / ai_method to `_`. Mirror of the Python
// filename build — directory build above keeps the separators so
// slash-delimited shot_name still becomes nested subfolders when
// no_subfolders=false.
const shotSegFlat = sanitizeSegment(values.shot_name).replace(/[\/\\]/g, "_");
const aiSegFlat = sanitizeSegment(values.ai_method).replace(/[\/\\]/g, "_");
const name = [shotSegFlat, aiSegFlat, version_str]
.filter(part => part.toString().trim() !== "")
.join("_") + cleanExtension(values.extension);
// Filter empties before joining so an empty output_directory (empty base
// + no_subfolders=true) doesn't leak a spurious leading "/" into file_path.
const file_path = [output_directory, name]
.filter(p => p)
.join("/")
.replace(/\\/g, "/")
.replace(/\/+/g, '/');
displayWidget.value = stripSentinelComponents(file_path);
}
this.setDirtyCanvas(true, true);
}, { serialize: false });
};
}
},
async nodeCreated(node) {
if (node.comfyClass !== "EasyResize") return;
// Function to update widgets for a mode
const updateWidgets = (modeName, colorName, value) => {
const colorWidgetIndex = node.widgets.findIndex(w => w.name === colorName);
if (value === "Custom") {
if (colorWidgetIndex === -1) {
node.addWidget("string", colorName, "0, 0, 0", null, { multiline: false });
}
} else {
if (colorWidgetIndex !== -1) {
node.widgets.splice(colorWidgetIndex, 1); // Remove
}
}
node.setDirtyCanvas(true, true);
};
// Pad color mode
const padModeWidget = node.widgets.find(w => w.name === "pad_color_mode");
const origPadCallback = padModeWidget.callback;
padModeWidget.callback = (value) => {
if (origPadCallback) origPadCallback.call(node, value);
updateWidgets("pad_color_mode", "pad_color", value);
};
updateWidgets("pad_color_mode", "pad_color", padModeWidget.value);
// Panel color mode
const panelModeWidget = node.widgets.find(w => w.name === "panel_color_mode");
const origPanelCallback = panelModeWidget.callback;
panelModeWidget.callback = (value) => {
if (origPanelCallback) origPanelCallback.call(node, value);
updateWidgets("panel_color_mode", "panel_color", value);
};
updateWidgets("panel_color_mode", "panel_color", panelModeWidget.value);
}
});