Port the Ideogram 4 Prompt Builder node and its visual bbox editor from
ComfyUI-KJNodes (GPL-3.0, by Kijai) into this pack, fully renamed so it can
coexist with KJNodes if both are installed:
- nodes/ideogram4_nodes.py class/node_id/display_name -> AI2Go*, category AI2Go/text
- web/js/ideogram4_prompt_builder.js extension name, CSS id + 'kjideo-' prefix -> 'ai2go-ideo-',
CSS vars '--kj-dock-' -> '--ai2go-dock-', template dir -> ai2go/ideogram4
- web/js/utility.js shared helpers the editor imports (sibling module)
- fonts/FreeMono.ttf preview font
- __init__.py registers AI2GoIdeogram4PromptBuilder
GPL-3.0 attribution headers added to each derived file. Python + JS syntax verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
JavaScript
280 lines
11 KiB
JavaScript
/*
|
||
* Part of ComfyUI-AI2Go-Utils.
|
||
*
|
||
* Derived from ComfyUI-KJNodes (web/js/ideogram4_prompt_builder.js, web/js/utility.js)
|
||
* by Kijai, licensed under GPL-3.0; this file and pack remain GPL-3.0.
|
||
* Upstream: https://github.com/kijai/ComfyUI-KJNodes
|
||
*/
|
||
const { app } = window.comfyAPI.app;
|
||
|
||
// ─── General-purpose helpers shared across KJNodes JS ───
|
||
|
||
export function makeUUID() {
|
||
let dt = new Date().getTime()
|
||
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||
const r = ((dt + Math.random() * 16) % 16) | 0
|
||
dt = Math.floor(dt / 16)
|
||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||
})
|
||
return uuid
|
||
}
|
||
|
||
export function chainCallback(object, property, callback) {
|
||
if (object == undefined) {
|
||
console.error("Tried to add callback to non-existant object")
|
||
return;
|
||
}
|
||
if (property in object) {
|
||
const callback_orig = object[property]
|
||
object[property] = function () {
|
||
const r = callback_orig.apply(this, arguments);
|
||
callback.apply(this, arguments);
|
||
return r
|
||
};
|
||
} else {
|
||
object[property] = callback;
|
||
}
|
||
}
|
||
|
||
export function hideWidgetForGood(node, widget, suffix = '') {
|
||
widget.origType = widget.type
|
||
widget.origComputeSize = widget.computeSize
|
||
widget.origSerializeValue = widget.serializeValue
|
||
widget.computeSize = () => [0, -4]
|
||
widget.type = "converted-widget" + suffix
|
||
if (widget.linkedWidgets) {
|
||
for (const w of widget.linkedWidgets) {
|
||
hideWidgetForGood(node, w, ':' + widget.name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Graph coordinate helpers ───
|
||
|
||
export function clientToCanvas(lgCanvas, clientX, clientY) {
|
||
const rect = lgCanvas.canvas.getBoundingClientRect();
|
||
return [
|
||
(clientX - rect.left) / lgCanvas.ds.scale - lgCanvas.ds.offset[0],
|
||
(clientY - rect.top) / lgCanvas.ds.scale - lgCanvas.ds.offset[1],
|
||
];
|
||
}
|
||
|
||
export function getNodeAtPoint(graph, cx, cy) {
|
||
for (let i = graph._nodes.length - 1; i >= 0; i--) {
|
||
if (graph._nodes[i].isPointInside(cx, cy)) return graph._nodes[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function typesCompatible(a, b) {
|
||
if (a === "*" || b === "*") return true;
|
||
if (a === b) return true;
|
||
if (typeof a !== "string" || typeof b !== "string") return false;
|
||
if (a.toUpperCase() === b.toUpperCase()) return true;
|
||
// ComfyUI accepts union types like "STRING,INT" — match if any token overlaps
|
||
if (a.includes(",") || b.includes(",")) {
|
||
const aTokens = a.toUpperCase().split(",").map(s => s.trim()).filter(Boolean);
|
||
const bTokens = b.toUpperCase().split(",").map(s => s.trim()).filter(Boolean);
|
||
if (aTokens.includes("*") || bTokens.includes("*")) return true;
|
||
return aTokens.some(t => bTokens.includes(t));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ─── Slot position helper ───
|
||
// Uses modern API (DOM-registered positions in Vue mode) with legacy fallback
|
||
export function getSlotPos(node, isInput, slotIdx) {
|
||
if (isInput && node.getInputPos) return node.getInputPos(slotIdx);
|
||
if (!isInput && node.getOutputPos) return node.getOutputPos(slotIdx);
|
||
const out = [0, 0];
|
||
node.getConnectionPos(isInput, slotIdx, out);
|
||
return out;
|
||
}
|
||
|
||
// ─── Middle-click pan passthrough for DOM widgets ───
|
||
// Allows panning the LiteGraph canvas via middle-click drag on any DOM element
|
||
export function addMiddleClickPan(element) {
|
||
const onMouseDown = (e) => {
|
||
if (e.button !== 1) return;
|
||
e.preventDefault();
|
||
const ds = app.canvas?.ds;
|
||
if (!ds) return;
|
||
const startX = e.clientX, startY = e.clientY;
|
||
const startOffsetX = ds.offset[0], startOffsetY = ds.offset[1];
|
||
const onMove = (me) => {
|
||
const scale = ds.scale || 1; // offset is graph-space; LiteGraph pans by (screen delta / scale)
|
||
ds.offset[0] = startOffsetX + (me.clientX - startX) / scale;
|
||
ds.offset[1] = startOffsetY + (me.clientY - startY) / scale;
|
||
app.canvas.setDirty(true, true);
|
||
};
|
||
const onUp = () => {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
};
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
};
|
||
element.addEventListener('mousedown', onMouseDown);
|
||
return () => element.removeEventListener('mousedown', onMouseDown);
|
||
}
|
||
|
||
// ─── Resolve preview image/video from a connected source node ───
|
||
// Walks the graph link to find what's connected to node.inputs[inputSlot]
|
||
// and returns { url, isVideo, videoEl } or null
|
||
export function resolveSourcePreview(node, inputSlot) {
|
||
if (!node.graph) return null;
|
||
const input = node.inputs?.[inputSlot];
|
||
if (!input || input.link == null) return null;
|
||
const link = node.graph.links?.get(input.link);
|
||
if (!link) return null;
|
||
const srcNode = node.graph.getNodeById(link.origin_id);
|
||
if (!srcNode) return null;
|
||
|
||
// Check for VHS-style video preview widget
|
||
const vpWidget = srcNode.widgets?.find((w) => w.name === "videopreview");
|
||
if (vpWidget?.videoEl?.src) {
|
||
return { isVideo: true, videoEl: vpWidget.videoEl };
|
||
}
|
||
|
||
// Look for a LoadImage "image" widget or LoadVideo "video" widget
|
||
const w = srcNode.widgets?.find((w) => w.name === "image" || w.name === "video");
|
||
if (w?.value) {
|
||
let subfolder = "", fname = w.value;
|
||
const lastSlash = fname.lastIndexOf("/");
|
||
if (lastSlash >= 0) { subfolder = fname.substring(0, lastSlash); fname = fname.substring(lastSlash + 1); }
|
||
const isVideo = w.name === "video";
|
||
const url = `/view?filename=${encodeURIComponent(fname)}&type=input&subfolder=${encodeURIComponent(subfolder)}`;
|
||
return { url, isVideo };
|
||
}
|
||
|
||
// Fallback: check node.imgs (set after execution)
|
||
if (srcNode.imgs?.length > 0 && srcNode.imgs[0].src) {
|
||
return { url: srcNode.imgs[0].src, isVideo: false };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ─── Source input watcher ───
|
||
// Watches a named IMAGE input for connection changes and source widget changes.
|
||
// Calls onChange(sources) when connections change or the source node's image/video widget changes.
|
||
// sources: array of { url, isVideo, videoEl } per connected IMAGE input matching inputName (or all if inputName is null).
|
||
// Returns a cleanup function.
|
||
export function watchImageInputs(node, inputName, onChange) {
|
||
let watchedWidgets = [];
|
||
|
||
function unwatchWidgets() {
|
||
for (const { widget, origCb } of watchedWidgets) widget.callback = origCb;
|
||
watchedWidgets = [];
|
||
}
|
||
|
||
function resolve() {
|
||
if (!node.inputs) return [];
|
||
const slots = inputName
|
||
? node.inputs.map((inp, i) => inp.name === inputName ? i : -1).filter(i => i >= 0)
|
||
: node.inputs.map((inp, i) => inp.type === "IMAGE" ? i : -1).filter(i => i >= 0);
|
||
return slots.map(i => resolveSourcePreview(node, i)).filter(s => s !== null);
|
||
}
|
||
|
||
function watch() {
|
||
unwatchWidgets();
|
||
if (!node.inputs || !node.graph) return;
|
||
const slots = inputName
|
||
? node.inputs.map((inp, i) => inp.name === inputName ? i : -1).filter(i => i >= 0)
|
||
: node.inputs.map((inp, i) => inp.type === "IMAGE" ? i : -1).filter(i => i >= 0);
|
||
for (const slotIdx of slots) {
|
||
const input = node.inputs[slotIdx];
|
||
if (!input || input.link == null) continue;
|
||
const link = node.graph.links?.get(input.link);
|
||
if (!link) continue;
|
||
const srcNode = node.graph.getNodeById(link.origin_id);
|
||
if (!srcNode) continue;
|
||
const w = srcNode.widgets?.find(w => w.name === "image" || w.name === "video");
|
||
if (!w) continue;
|
||
const origCb = w.callback;
|
||
w.callback = function (...args) {
|
||
if (origCb) origCb.apply(this, args);
|
||
setTimeout(() => onChange(resolve()), 100);
|
||
};
|
||
watchedWidgets.push({ widget: w, origCb });
|
||
}
|
||
}
|
||
|
||
chainCallback(node, "onConnectionsChange", function (type) {
|
||
// Only react to input connection changes, not output
|
||
if (type != null && type !== 1) return;
|
||
setTimeout(() => {
|
||
watch();
|
||
onChange(resolve());
|
||
}, 100);
|
||
});
|
||
|
||
chainCallback(node, "onRemoved", unwatchWidgets);
|
||
|
||
return unwatchWidgets;
|
||
}
|
||
|
||
// ─── Video frame capture ───
|
||
// Draws the current frame of a video element to a fresh canvas at native resolution.
|
||
// If the video isn't decoded yet (readyState < 2), waits once for "loadeddata" before capturing.
|
||
// Calls callback(canvas) with the resulting canvas. Caller decides whether to use it directly
|
||
// (drawImage accepts canvas) or convert via toDataURL.
|
||
export function captureVideoFrame(videoEl, callback) {
|
||
const capture = () => {
|
||
if (!videoEl.videoWidth || !videoEl.videoHeight) return;
|
||
const c = document.createElement("canvas");
|
||
c.width = videoEl.videoWidth;
|
||
c.height = videoEl.videoHeight;
|
||
c.getContext("2d").drawImage(videoEl, 0, 0);
|
||
callback(c);
|
||
};
|
||
if (videoEl.readyState >= 2) {
|
||
capture();
|
||
} else {
|
||
const onReady = () => { videoEl.removeEventListener("loadeddata", onReady); capture(); };
|
||
videoEl.addEventListener("loadeddata", onReady);
|
||
}
|
||
}
|
||
|
||
// ─── Bounding box hit test ───
|
||
// Tests whether (mx, my) hits a corner handle or the interior of a rect defined by (x1, y1)–(x2, y2).
|
||
// Returns "resize-tl", "resize-tr", "resize-bl", "resize-br", "resize-t", "resize-b", "resize-l", "resize-r", "move", or null.
|
||
export function rectHitTest(mx, my, x1, y1, x2, y2, radius) {
|
||
const hit = (cx, cy) => Math.abs(mx - cx) < radius && Math.abs(my - cy) < radius;
|
||
// Corners first (higher priority)
|
||
if (hit(x1, y1)) return "resize-tl";
|
||
if (hit(x2, y1)) return "resize-tr";
|
||
if (hit(x1, y2)) return "resize-bl";
|
||
if (hit(x2, y2)) return "resize-br";
|
||
// Edges
|
||
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < radius) return "resize-t";
|
||
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < radius) return "resize-b";
|
||
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < radius) return "resize-l";
|
||
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < radius) return "resize-r";
|
||
// Interior
|
||
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return "move";
|
||
return null;
|
||
}
|
||
|
||
// Returns the appropriate CSS cursor for a bbox hit mode string.
|
||
export function cursorForBboxMode(mode) {
|
||
if (mode === "move") return "move";
|
||
if (mode === "resize-tl" || mode === "resize-br") return "nwse-resize";
|
||
if (mode === "resize-tr" || mode === "resize-bl") return "nesw-resize";
|
||
if (mode === "resize-t" || mode === "resize-b") return "ns-resize";
|
||
if (mode === "resize-l" || mode === "resize-r") return "ew-resize";
|
||
return null;
|
||
}
|
||
|
||
// ─── Wheel zoom passthrough for DOM widgets ───
|
||
// Re-dispatches wheel events to the LiteGraph canvas for zoom
|
||
export function addWheelPassthrough(element) {
|
||
element.addEventListener("wheel", (e) => {
|
||
const gc = document.getElementById("graph-canvas");
|
||
if (gc) {
|
||
gc.dispatchEvent(new WheelEvent(e.type, e));
|
||
e.preventDefault();
|
||
}
|
||
}, { passive: false });
|
||
}
|
||
|