Files
ChrisandClaude Opus 4.8 48f911d97f Add AI2Go Ideogram 4 Prompt Builder (copy-and-own from KJNodes)
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>
2026-06-24 23:02:37 +02:00

280 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 });
}