Files
MajoorWaldi-ComfyUI-Majoor-…/js/features/viewer/nodeStream/NodeStreamController.js
T
MajoorWaldi dc72be0139 feat: enhance JS testing and linting setup
- Added coverage reporting for Vitest with configuration in vitest.config.mjs
- Updated package.json to include new scripts for JS coverage, linting, and formatting
- Modified run_quality_gate.py to allow skipping ESLint and Prettier checks
- Introduced .prettierrc for consistent code formatting
- Created .prettierignore to exclude specific files and directories from formatting
2026-03-28 14:14:28 +01:00

622 lines
17 KiB
JavaScript

/**
* NodeStreamController
*
* Selection-only node preview for the Majoor Floating Viewer.
*
* When Node Stream mode is active, selecting a node in the ComfyUI canvas
* immediately pushes that node's existing preview to the MFV if one is
* available. No workflow execution event is required.
*
* NOTE:
* This feature is currently disabled by NODE_STREAM_FEATURE_ENABLED. The code
* remains in-tree on purpose so it can be reactivated later without being
* rebuilt from scratch.
*/
import { registerAdapter, listAdapters } from "./NodeStreamRegistry.js";
import { DefaultImageAdapter } from "./adapters/DefaultImageAdapter.js";
import { VideoAdapter } from "./adapters/VideoAdapter.js";
import { KnownNodesAdapter } from "./adapters/KnownNodesAdapter.js";
import { NODE_STREAM_FEATURE_ENABLED } from "./nodeStreamFeatureFlag.js";
// Bootstrap built-in adapters for compatibility with the public API exposed in
// entry.js. Selection-only mode does not consume execution output updates, but
// external code may still inspect the registered adapter list.
if (NODE_STREAM_FEATURE_ENABLED) {
registerAdapter(DefaultImageAdapter);
registerAdapter(KnownNodesAdapter);
registerAdapter(VideoAdapter);
}
/** @type {"selected" | "pinned" | "all"} */
let _watchMode = "selected";
/** @type {string | null} */
let _pinnedNodeId = null;
/** @type {string | null} */
let _selectedNodeId = null;
/** @type {boolean} */
let _active = false;
/** @type {((fileData: object) => void) | null} */
let _outputCallback = null;
/** @type {((nodeId: string, classType: string) => void) | null} */
let _statusCallback = null;
/** @type {object | null} */
let _appRef = null;
/** @type {ReturnType<typeof setInterval> | null} */
let _selectionPollTimer = null;
/** @type {string | null} */
let _lastPreviewKey = null;
/** @type {string | null} */
let _lastPreviewNodeId = null;
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".avif", ".gif", ".bmp", ".tiff"]);
const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
const MAX_DOWNSTREAM_DEPTH = 12;
const MAX_DOWNSTREAM_NODES = 96;
function _classTypeOf(node) {
return node?.comfyClass || node?.type || null;
}
function _parseViewUrl(src) {
try {
const url = new URL(src, window.location.href);
const filename = url.searchParams.get("filename") || "";
if (!filename) return null;
return {
filename,
subfolder: url.searchParams.get("subfolder") || "",
type: url.searchParams.get("type") || "output",
};
} catch {
return null;
}
}
function _parseWidgetFilename(raw) {
if (raw == null || typeof raw !== "string") return null;
const normalized = raw.trim().replace(/\\/g, "/");
if (!normalized) return null;
const slashIndex = normalized.lastIndexOf("/");
return {
filename: slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized,
subfolder: slashIndex >= 0 ? normalized.slice(0, slashIndex) : "",
};
}
function _fileExtension(filename) {
if (!filename) return "";
const dotIndex = String(filename).lastIndexOf(".");
return dotIndex >= 0 ? String(filename).slice(dotIndex).toLowerCase() : "";
}
function _guessMediaKind(node, filename = "") {
const ext = _fileExtension(filename);
if (VIDEO_EXTS.has(ext)) return "video";
if (IMAGE_EXTS.has(ext)) return "image";
const classType = String(_classTypeOf(node) || "").toLowerCase();
if (classType.includes("video")) return "video";
return "image";
}
function _withNodeMetadata(node, fileData, source) {
return {
...fileData,
kind: fileData?.kind || _guessMediaKind(node, fileData?.filename),
_nodeId: String(node?.id ?? ""),
_classType: _classTypeOf(node) || "",
_source: source,
};
}
function _getSelectedCanvasNodes() {
const selected = _appRef?.canvas?.selected_nodes ?? _appRef?.canvas?.selectedNodes ?? null;
if (!selected) return [];
if (Array.isArray(selected)) {
return selected.filter(Boolean);
}
if (selected instanceof Map) {
return Array.from(selected.values()).filter(Boolean);
}
if (typeof selected === "object") {
return Object.values(selected).filter(Boolean);
}
return [];
}
function _getNodeById(nodeId) {
if (nodeId == null || !_appRef?.graph) return null;
try {
return _appRef.graph.getNodeById(Number(nodeId)) || null;
} catch {
return null;
}
}
function _getAllGraphNodes() {
const graph = _appRef?.graph;
if (!graph) return [];
if (Array.isArray(graph._nodes)) {
return graph._nodes.filter(Boolean);
}
if (graph._nodes_by_id instanceof Map) {
return Array.from(graph._nodes_by_id.values()).filter(Boolean);
}
if (graph._nodes_by_id && typeof graph._nodes_by_id === "object") {
return Object.values(graph._nodes_by_id).filter(Boolean);
}
return [];
}
function _findTargetNodeIdByInputLink(linkId) {
if (linkId == null) return null;
const normalizedLinkId = String(linkId);
for (const node of _getAllGraphNodes()) {
if (!Array.isArray(node?.inputs)) continue;
for (const input of node.inputs) {
if (input?.link == null) continue;
if (String(input.link) === normalizedLinkId) {
return String(node.id ?? "");
}
}
}
return null;
}
function _getGraphLinkRecord(linkId) {
if (linkId == null) return null;
const graphLinks = _appRef?.graph?.links ?? _appRef?.graph?._links ?? null;
if (!graphLinks) return null;
const numericId = Number(linkId);
const stringId = String(linkId);
if (graphLinks instanceof Map) {
return (
graphLinks.get(linkId) || graphLinks.get(numericId) || graphLinks.get(stringId) || null
);
}
if (Array.isArray(graphLinks)) {
const direct = graphLinks[numericId];
if (direct) return direct;
for (const item of graphLinks) {
if (!item) continue;
if (Array.isArray(item) && String(item[0]) === stringId) return item;
const itemId = item.id ?? item.link_id ?? item.linkId ?? null;
if (itemId != null && String(itemId) === stringId) return item;
}
return null;
}
if (typeof graphLinks === "object") {
return graphLinks[linkId] || graphLinks[numericId] || graphLinks[stringId] || null;
}
return null;
}
function _getTargetNodeIdForLink(linkId) {
const linkRecord = _getGraphLinkRecord(linkId);
if (Array.isArray(linkRecord) && linkRecord.length >= 4) {
return String(linkRecord[3] ?? "");
}
if (linkRecord && typeof linkRecord === "object") {
const targetId = linkRecord.target_id ?? linkRecord.targetId ?? linkRecord.to ?? null;
if (targetId != null) {
return String(targetId);
}
}
return _findTargetNodeIdByInputLink(linkId);
}
function _getOutputLinkIds(node) {
if (!Array.isArray(node?.outputs)) return [];
const linkIds = [];
for (const output of node.outputs) {
const links = output?.links;
if (Array.isArray(links)) {
for (const linkId of links) {
if (linkId != null) linkIds.push(linkId);
}
} else if (links != null) {
linkIds.push(links);
}
if (output?.link != null) {
linkIds.push(output.link);
}
}
return Array.from(new Set(linkIds.map((linkId) => String(linkId))));
}
function _getDownstreamNodes(node) {
const downstreamNodes = [];
const seenNodeIds = new Set();
for (const linkId of _getOutputLinkIds(node)) {
const targetNodeId = _getTargetNodeIdForLink(linkId);
if (!targetNodeId || seenNodeIds.has(targetNodeId)) continue;
const targetNode = _getNodeById(targetNodeId);
if (!targetNode) continue;
seenNodeIds.add(targetNodeId);
downstreamNodes.push(targetNode);
}
return downstreamNodes;
}
function _syncSelectedNodeState() {
const nodes = _getSelectedCanvasNodes();
const nextNode = nodes[0] || null;
const nextNodeId = nextNode ? String(nextNode.id ?? "") : null;
if (nextNodeId !== _selectedNodeId) {
_selectedNodeId = nextNodeId;
if (nextNodeId) {
_statusCallback?.(nextNodeId, _classTypeOf(nextNode) || "");
}
} else if (!nextNodeId) {
_selectedNodeId = null;
}
return nodes;
}
function _extractFromNodeImgs(node) {
if (!node) return null;
const imgs = node.imgs;
if (!Array.isArray(imgs) || imgs.length === 0) return null;
const lastSrc = imgs[imgs.length - 1]?.src || imgs[0]?.src;
if (!lastSrc) return null;
const parsed = _parseViewUrl(lastSrc);
if (!parsed?.filename) return null;
return _withNodeMetadata(node, { ...parsed, kind: "image" }, "canvas");
}
function _extractFromWidgetMedia(node) {
if (!node || !Array.isArray(node.widgets)) return null;
for (const widget of node.widgets) {
const el = widget?.element;
if (!el) continue;
const videoEl =
typeof HTMLVideoElement !== "undefined" && el instanceof HTMLVideoElement
? el
: el.querySelector?.("video");
if (videoEl?.src) {
const parsed = _parseViewUrl(videoEl.src);
if (parsed?.filename) {
return _withNodeMetadata(node, { ...parsed, kind: "video" }, "widget");
}
}
const imageEl =
typeof HTMLImageElement !== "undefined" && el instanceof HTMLImageElement
? el
: el.querySelector?.("img");
if (!imageEl?.src) continue;
const parsed = _parseViewUrl(imageEl.src);
if (parsed?.filename) {
return _withNodeMetadata(node, { ...parsed, kind: "image" }, "widget");
}
}
return null;
}
function _extractFromLoadWidget(node) {
if (!node || !Array.isArray(node.widgets) || !node.widgets.length) return null;
const classType = String(_classTypeOf(node) || "").toLowerCase();
const firstValue = node.widgets[0]?.value;
if (typeof firstValue !== "string") return null;
const parsed = _parseWidgetFilename(firstValue);
if (!parsed?.filename) return null;
const ext = _fileExtension(parsed.filename);
const looksLikeMediaFile = IMAGE_EXTS.has(ext) || VIDEO_EXTS.has(ext);
const looksLikeLoadNode = /(load|upload|loader|fromurl|folder|input)/.test(classType);
if (!looksLikeMediaFile && !looksLikeLoadNode) return null;
return _withNodeMetadata(
node,
{
...parsed,
type: "input",
kind: _guessMediaKind(node, parsed.filename),
},
"widget-value",
);
}
function _extractFromCanvasNode(node) {
return (
_extractFromNodeImgs(node) || _extractFromWidgetMedia(node) || _extractFromLoadWidget(node)
);
}
function _resolvePreviewForNode(node) {
if (!node) return null;
const rootNodeId = String(node.id ?? "");
const rootClassType = _classTypeOf(node) || "";
const queue = [{ node, depth: 0 }];
const visitedNodeIds = new Set(rootNodeId ? [rootNodeId] : []);
let scannedNodes = 0;
while (queue.length > 0 && scannedNodes < MAX_DOWNSTREAM_NODES) {
const current = queue.shift();
if (!current?.node) continue;
scannedNodes += 1;
const preview = _extractFromCanvasNode(current.node);
if (preview) {
const previewNodeId = preview._nodeId || String(current.node.id ?? "");
const previewClassType = preview._classType || _classTypeOf(current.node) || "";
return {
...preview,
_nodeId: rootNodeId || previewNodeId,
_classType: rootClassType || previewClassType,
_previewNodeId: previewNodeId,
_previewClassType: previewClassType,
_source:
previewNodeId === rootNodeId ? preview._source || "canvas" : "graph-downstream",
};
}
if (current.depth >= MAX_DOWNSTREAM_DEPTH) continue;
for (const downstreamNode of _getDownstreamNodes(current.node)) {
const downstreamNodeId = String(downstreamNode?.id ?? "");
if (!downstreamNodeId || visitedNodeIds.has(downstreamNodeId)) continue;
visitedNodeIds.add(downstreamNodeId);
queue.push({ node: downstreamNode, depth: current.depth + 1 });
}
}
return null;
}
function _fileDataSignature(fileData) {
if (!fileData) return "";
return [
fileData._nodeId || "",
fileData.kind || "",
fileData.type || "",
fileData.subfolder || "",
fileData.filename || "",
].join("|");
}
function _resetPreviewState() {
_lastPreviewKey = null;
_lastPreviewNodeId = null;
}
function _getWatchedNode() {
if (_watchMode === "pinned") {
return _getNodeById(_pinnedNodeId);
}
return _getSelectedCanvasNodes()[0] || null;
}
function _emitPreviewFromWatchedNode({ force = false } = {}) {
if (!_active || !_outputCallback || !_appRef?.graph) return;
const node = _getWatchedNode();
const nodeId = node ? String(node.id ?? "") : null;
if (!nodeId) {
_resetPreviewState();
return;
}
if (_watchMode === "pinned") {
_statusCallback?.(nodeId, _classTypeOf(node) || "");
}
const fileData = _resolvePreviewForNode(node);
if (!fileData) {
_resetPreviewState();
return;
}
const previewKey = _fileDataSignature(fileData);
const nodeChanged = nodeId !== _lastPreviewNodeId;
if (!force && !nodeChanged) return;
_lastPreviewNodeId = nodeId;
_lastPreviewKey = previewKey;
_outputCallback(fileData);
}
function _pollNodePreview() {
const previousSelectedNodeId = _selectedNodeId;
_syncSelectedNodeState();
const selectionChanged = _selectedNodeId !== previousSelectedNodeId;
if (!_active) {
_resetPreviewState();
return;
}
const force = _watchMode !== "pinned" && selectionChanged;
_emitPreviewFromWatchedNode({ force });
}
function _startSelectionPolling() {
if (_selectionPollTimer) return;
_selectionPollTimer = setInterval(_pollNodePreview, 150);
_pollNodePreview();
}
function _stopSelectionPolling() {
if (_selectionPollTimer) {
clearInterval(_selectionPollTimer);
_selectionPollTimer = null;
}
_resetPreviewState();
}
/**
* Kept as a compatibility shim for entry.js.
* Node Stream is selection-only and intentionally ignores execution output
* updates.
*/
export function onNodeOutputs(nodeOutputs, graphInfo) {
void nodeOutputs;
void graphInfo;
if (!NODE_STREAM_FEATURE_ENABLED) return;
}
export function initNodeStream({ app, onOutput, onStatus } = {}) {
if (!NODE_STREAM_FEATURE_ENABLED) {
_active = false;
_outputCallback = null;
_statusCallback = null;
_appRef = app || null;
_stopSelectionPolling();
console.debug("[NodeStream] Disabled by feature flag");
return;
}
_outputCallback = onOutput || null;
_statusCallback = onStatus || null;
_appRef = app || null;
if (app) {
_syncSelectedNodeState();
}
console.debug("[NodeStream] Controller initialized (selection-only preview mode)");
}
export function setNodeStreamActive(active) {
if (!NODE_STREAM_FEATURE_ENABLED) {
void active;
_active = false;
_selectedNodeId = null;
_stopSelectionPolling();
return;
}
_active = Boolean(active);
if (!_active) {
_selectedNodeId = null;
_stopSelectionPolling();
return;
}
_resetPreviewState();
_syncSelectedNodeState();
if (_selectionPollTimer) {
_pollNodePreview();
return;
}
_startSelectionPolling();
}
export function getNodeStreamActive() {
if (!NODE_STREAM_FEATURE_ENABLED) return false;
return _active;
}
export function setWatchMode(mode) {
if (!NODE_STREAM_FEATURE_ENABLED) {
void mode;
return;
}
if (mode === "selected" || mode === "pinned" || mode === "all") {
_watchMode = mode;
_resetPreviewState();
if (_active) {
_emitPreviewFromWatchedNode({ force: true });
}
}
}
export function getWatchMode() {
return _watchMode;
}
export function pinNode(nodeId) {
if (!NODE_STREAM_FEATURE_ENABLED) {
void nodeId;
return;
}
if (nodeId == null) {
_pinnedNodeId = null;
if (_watchMode === "pinned") _watchMode = "selected";
_resetPreviewState();
if (_active) {
_emitPreviewFromWatchedNode({ force: true });
}
return;
}
_pinnedNodeId = String(nodeId);
_watchMode = "pinned";
_resetPreviewState();
if (_active) {
_emitPreviewFromWatchedNode({ force: true });
}
}
export function getPinnedNodeId() {
if (!NODE_STREAM_FEATURE_ENABLED) return null;
return _pinnedNodeId;
}
export function getSelectedNodeId() {
if (!NODE_STREAM_FEATURE_ENABLED) return null;
_syncSelectedNodeState();
return _selectedNodeId;
}
export function teardownNodeStream(app) {
void app;
_active = false;
_selectedNodeId = null;
_pinnedNodeId = null;
_outputCallback = null;
_statusCallback = null;
_appRef = null;
_stopSelectionPolling();
console.debug("[NodeStream] Controller torn down");
}
export { registerAdapter, listAdapters };