498 lines
17 KiB
JavaScript
498 lines
17 KiB
JavaScript
// ComfyUI graph lookup, widget access, backend events, and global listener lifecycle.
|
|
import { api } from "../../../../scripts/api.js";
|
|
import { app } from "../../../../scripts/app.js";
|
|
import {
|
|
OUTPUT_SETTINGS_PROPERTY,
|
|
TARGET_NODES,
|
|
TIMELINE_GLOBAL_LISTENER_STATE,
|
|
TIMELINE_GLOBAL_LISTENER_TOKEN,
|
|
TIMELINE_STATUS_DURATION_MS,
|
|
UI_INSTANCE_PROPERTY,
|
|
UI_INSTANCE_WIDGET,
|
|
} from "./constants.js";
|
|
import { syncTimelineWidget } from "./model.js";
|
|
import { setupNode } from "./node_setup.js";
|
|
export function isTargetNode(nodeOrName) {
|
|
const name = typeof nodeOrName === "string" ? nodeOrName : nodeOrName ? nodeOrName.type : null;
|
|
return TARGET_NODES.has(name);
|
|
}
|
|
|
|
export function disposeTimelineGlobalListenerState(state) {
|
|
for (const dispose of [...(state.disposers || [])].reverse()) {
|
|
try {
|
|
dispose();
|
|
} catch (error) {
|
|
console.warn("[ETK timeline] Failed to remove stale global listener", error);
|
|
}
|
|
}
|
|
state.installed = new Set();
|
|
state.disposers = [];
|
|
}
|
|
|
|
export function timelineGlobalListenerState() {
|
|
let state = window[TIMELINE_GLOBAL_LISTENER_STATE];
|
|
if (!state || typeof state !== "object") {
|
|
state = { token: null, installed: new Set(), disposers: [] };
|
|
window[TIMELINE_GLOBAL_LISTENER_STATE] = state;
|
|
}
|
|
if (!(state.installed instanceof Set)) state.installed = new Set(state.installed || []);
|
|
if (!Array.isArray(state.disposers)) state.disposers = [];
|
|
if (state.token && state.token !== TIMELINE_GLOBAL_LISTENER_TOKEN) {
|
|
disposeTimelineGlobalListenerState(state);
|
|
}
|
|
state.token = TIMELINE_GLOBAL_LISTENER_TOKEN;
|
|
return state;
|
|
}
|
|
|
|
export function resetTimelineGlobalListeners() {
|
|
const state = timelineGlobalListenerState();
|
|
disposeTimelineGlobalListenerState(state);
|
|
state.token = TIMELINE_GLOBAL_LISTENER_TOKEN;
|
|
}
|
|
|
|
export function addTimelineWindowListener(key, eventName, handler, options = false) {
|
|
const state = timelineGlobalListenerState();
|
|
if (state.installed.has(key)) return;
|
|
let effectiveOptions = options;
|
|
let controller = null;
|
|
if (typeof AbortController !== "undefined") {
|
|
controller = new AbortController();
|
|
effectiveOptions = typeof options === "boolean" ? { capture: options, signal: controller.signal } : { ...(options || {}), signal: controller.signal };
|
|
}
|
|
window.addEventListener(eventName, handler, effectiveOptions);
|
|
state.installed.add(key);
|
|
state.disposers.push(() => {
|
|
if (controller) {
|
|
controller.abort();
|
|
} else if (typeof window.removeEventListener === "function") {
|
|
window.removeEventListener(eventName, handler, effectiveOptions);
|
|
}
|
|
state.installed.delete(key);
|
|
});
|
|
}
|
|
|
|
export function errorText(error) {
|
|
if (!error) return "unknown error";
|
|
return error.message ? String(error.message) : String(error);
|
|
}
|
|
|
|
export function setTimelineStatus(node, text, tone = "info", durationMs = TIMELINE_STATUS_DURATION_MS) {
|
|
if (!node) return;
|
|
const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
node.etkTimelineStatus = { text: String(text || ""), tone, expiresAt: durationMs > 0 ? now + durationMs : 0 };
|
|
if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true);
|
|
if (node.graph && typeof node.graph.setDirtyCanvas === "function") node.graph.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
export function clamp(value, min, max) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
export function makeId() {
|
|
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
|
|
return `etk-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
export function getWidget(node, name) {
|
|
return node.widgets ? node.widgets.find((widget) => widget.name === name) : undefined;
|
|
}
|
|
|
|
export function getWidgetValue(node, name, fallback) {
|
|
const widget = getWidget(node, name);
|
|
return widget && widget.value != null ? widget.value : fallback;
|
|
}
|
|
|
|
export function getInput(node, name) {
|
|
return node.inputs ? node.inputs.find((input) => input.name === name) : undefined;
|
|
}
|
|
|
|
export function graphLinkById(graph, linkId) {
|
|
if (!graph || linkId == null) return null;
|
|
const candidates = [linkId, Number(linkId), String(linkId)];
|
|
for (const store of [graph.links, graph._links]) {
|
|
if (!store) continue;
|
|
if (typeof store.get === "function") {
|
|
for (const candidate of candidates) {
|
|
const link = store.get(candidate);
|
|
if (link) return link;
|
|
}
|
|
} else {
|
|
for (const candidate of candidates) {
|
|
const link = store[candidate];
|
|
if (link) return link;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function valueFromCandidate(value) {
|
|
if (value == null) return undefined;
|
|
if (Array.isArray(value)) return value.length ? value[0] : undefined;
|
|
if (typeof value === "object") {
|
|
if ("value" in value) return value.value;
|
|
if ("text" in value) return value.text;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function linkedSourceInfo(node, name) {
|
|
const input = getInput(node, name);
|
|
const linkId = input ? input.link : null;
|
|
const graph = node.graph || app.graph;
|
|
if (linkId == null || !graph) return null;
|
|
const link = graphLinkById(graph, linkId);
|
|
if (!link) return null;
|
|
const originId = link.origin_id ?? link.originId;
|
|
const originSlot = link.origin_slot ?? link.originSlot ?? 0;
|
|
const sourceNode =
|
|
typeof graph.getNodeById === "function"
|
|
? graph.getNodeById(originId)
|
|
: graph._nodes
|
|
? graph._nodes.find((candidate) => candidate.id === originId)
|
|
: null;
|
|
if (!sourceNode) return null;
|
|
const output = sourceNode.outputs ? sourceNode.outputs[originSlot] : null;
|
|
return { link, sourceNode, output };
|
|
}
|
|
|
|
export function linkedSourceNode(node, name) {
|
|
const info = linkedSourceInfo(node, name);
|
|
return info ? info.sourceNode : null;
|
|
}
|
|
|
|
export function valueFromLinkedSource(info, preferredName) {
|
|
if (!info || !info.sourceNode) return undefined;
|
|
const sourceNode = info.sourceNode;
|
|
const widgetNames = [preferredName];
|
|
if (info.output && info.output.name) widgetNames.push(info.output.name);
|
|
const lowerNames = widgetNames.map((name) => String(name || "").toLowerCase()).filter(Boolean);
|
|
const widgets = sourceNode.widgets || [];
|
|
const namedWidget = widgets.find((widget) => lowerNames.includes((widget.name || "").toLowerCase()));
|
|
if (namedWidget && namedWidget.value != null) return namedWidget.value;
|
|
if (widgets.length === 1 && widgets[0].value != null) return widgets[0].value;
|
|
|
|
if (sourceNode.properties) {
|
|
let propertyValue = sourceNode.properties[preferredName];
|
|
const candidate = valueFromCandidate(propertyValue);
|
|
if (candidate != null) return candidate;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export function getNumericControlValue(node, name, fallback, options = {}) {
|
|
const backendValue = node.etkTimelineBackendSettings ? node.etkTimelineBackendSettings[name] : undefined;
|
|
const backendNumeric = Number(valueFromCandidate(backendValue));
|
|
if (
|
|
Number.isFinite(backendNumeric) &&
|
|
(options.min == null || backendNumeric >= options.min) &&
|
|
(!options.rejectZero || backendNumeric !== 0)
|
|
) {
|
|
return backendNumeric;
|
|
}
|
|
|
|
const sourceValue = valueFromLinkedSource(linkedSourceInfo(node, name), name);
|
|
const widgetValue = getWidgetValue(node, name, fallback);
|
|
const candidates = sourceValue != null ? [sourceValue, widgetValue, fallback] : [widgetValue, fallback];
|
|
for (const candidate of candidates) {
|
|
const numeric = Number(valueFromCandidate(candidate));
|
|
if (!Number.isFinite(numeric)) continue;
|
|
if (options.min != null && numeric < options.min) continue;
|
|
if (options.rejectZero && numeric === 0) continue;
|
|
return numeric;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export function setWidgetValue(node, name, value, notify = false) {
|
|
const widget = getWidget(node, name);
|
|
if (!widget) return;
|
|
widget.value = value;
|
|
if (notify && typeof widget.callback === "function") widget.callback(value, node, widget);
|
|
}
|
|
|
|
export function hideWidget(widget, flagName, options = {}) {
|
|
if (!widget || widget[flagName]) return;
|
|
widget[flagName] = true;
|
|
widget.hidden = true;
|
|
if (options.serialize === false) widget.serialize = false;
|
|
widget.computeSize = () => [0, 0];
|
|
widget.draw = () => {};
|
|
if (widget.element) widget.element.style.display = "none";
|
|
}
|
|
|
|
export function ensureUiInstanceId(node) {
|
|
if (!node.properties) node.properties = {};
|
|
let value = node.etkTimelineUiInstanceId;
|
|
if (!value) {
|
|
value = makeId();
|
|
node.etkTimelineUiInstanceId = value;
|
|
}
|
|
node.properties[UI_INSTANCE_PROPERTY] = value;
|
|
const widget = getWidget(node, UI_INSTANCE_WIDGET);
|
|
if (widget) {
|
|
widget.value = value;
|
|
if (widget.element) widget.element.value = value;
|
|
hideWidget(widget, "etkTimelineUiInstanceHidden", { serialize: false });
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function uiPayloadValue(message, name) {
|
|
if (!message) return undefined;
|
|
const source = message.output && message.output[name] != null ? message.output : message;
|
|
const value = source[name];
|
|
return Array.isArray(value) ? value[0] : value;
|
|
}
|
|
|
|
export function parsePayloadObject(payload) {
|
|
if (payload == null) return null;
|
|
if (Array.isArray(payload)) payload = payload.length ? payload[0] : null;
|
|
if (typeof payload === "string") {
|
|
try {
|
|
payload = JSON.parse(payload);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return payload && typeof payload === "object" ? payload : null;
|
|
}
|
|
|
|
export function payloadUiInstanceId(payload) {
|
|
const parsed = parsePayloadObject(payload);
|
|
return parsed && parsed.ui_instance_id != null ? String(parsed.ui_instance_id) : "";
|
|
}
|
|
|
|
export function payloadsMatchNode(node, payloads) {
|
|
const nodeId = ensureUiInstanceId(node);
|
|
return payloads.some((payload) => {
|
|
const payloadId = payloadUiInstanceId(payload);
|
|
return !!payloadId && payloadId === nodeId;
|
|
});
|
|
}
|
|
|
|
export function normalizeTimelineSettings(payload) {
|
|
if (payload == null) return null;
|
|
if (Array.isArray(payload)) payload = payload.length ? payload[0] : null;
|
|
if (typeof payload === "string") {
|
|
try {
|
|
payload = JSON.parse(payload);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (!payload || typeof payload !== "object") return null;
|
|
const settings = {};
|
|
for (const name of ["width", "height", "length", "fps"]) {
|
|
const numeric = Number(valueFromCandidate(payload[name]));
|
|
if (Number.isFinite(numeric)) settings[name] = name === "fps" ? Math.max(0.001, numeric) : Math.round(numeric);
|
|
}
|
|
return Object.keys(settings).length ? settings : null;
|
|
}
|
|
|
|
export function applyBackendTimelineSettings(node, payload) {
|
|
const settings = normalizeTimelineSettings(payload);
|
|
if (!settings) return false;
|
|
const previousSettings = node.etkTimelineBackendSettings || {};
|
|
const nextSettings = { ...previousSettings, ...settings };
|
|
node.etkTimelineBackendSettings = nextSettings;
|
|
if (!node.properties) node.properties = {};
|
|
node.properties[OUTPUT_SETTINGS_PROPERTY] = nextSettings;
|
|
let changed = false;
|
|
for (const name of ["width", "height", "length", "fps"]) {
|
|
if (settings[name] == null) continue;
|
|
if (previousSettings[name] !== settings[name]) changed = true;
|
|
setWidgetValue(node, name, settings[name], true);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
export function graphNodes(graph) {
|
|
return graph ? graph.nodes || graph._nodes || [] : [];
|
|
}
|
|
|
|
export function rootGraph() {
|
|
return app.rootGraph || (app.graph && app.graph.rootGraph) || app.graph || null;
|
|
}
|
|
|
|
export function activeCanvasGraph() {
|
|
const canvas = app.canvas;
|
|
if (canvas && typeof canvas.getCurrentGraph === "function") {
|
|
return canvas.getCurrentGraph() || canvas.graph || app.graph || null;
|
|
}
|
|
return (canvas && canvas.graph) || app.graph || null;
|
|
}
|
|
|
|
export function uniqueGraphs(...graphs) {
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const graph of graphs) {
|
|
if (!graph || seen.has(graph)) continue;
|
|
seen.add(graph);
|
|
result.push(graph);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function findNodeInGraphByLocalId(graph, nodeId) {
|
|
if (nodeId == null || !graph) return null;
|
|
const normalizedId = String(nodeId);
|
|
const numericId = Number(nodeId);
|
|
if (typeof graph.getNodeById === "function" && Number.isFinite(numericId)) {
|
|
const node = graph.getNodeById(numericId);
|
|
if (node) return node;
|
|
}
|
|
return graphNodes(graph).find((node) => String(node.id) === normalizedId) || null;
|
|
}
|
|
|
|
export function walkGraphs(graph, callback, visited = new Set()) {
|
|
if (!graph || visited.has(graph)) return null;
|
|
visited.add(graph);
|
|
|
|
for (const node of graphNodes(graph)) {
|
|
const result = callback(node, graph);
|
|
if (result) return result;
|
|
if (node.subgraph) {
|
|
const subgraphResult = walkGraphs(node.subgraph, callback, visited);
|
|
if (subgraphResult) return subgraphResult;
|
|
}
|
|
}
|
|
|
|
const subgraphs = graph.subgraphs;
|
|
if (subgraphs && typeof subgraphs.values === "function") {
|
|
for (const subgraph of subgraphs.values()) {
|
|
const subgraphResult = walkGraphs(subgraph, callback, visited);
|
|
if (subgraphResult) return subgraphResult;
|
|
}
|
|
} else if (Array.isArray(subgraphs)) {
|
|
for (const subgraph of subgraphs) {
|
|
const subgraphResult = walkGraphs(subgraph, callback, visited);
|
|
if (subgraphResult) return subgraphResult;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function findTimelineNodeByLocalId(nodeId, preferredGraph = null) {
|
|
const normalizedId = String(nodeId);
|
|
for (const graph of uniqueGraphs(preferredGraph, activeCanvasGraph(), rootGraph())) {
|
|
const node = findNodeInGraphByLocalId(graph, normalizedId);
|
|
if (isTargetNode(node)) return node;
|
|
}
|
|
return walkGraphs(rootGraph(), (node) => {
|
|
return String(node.id) === normalizedId && isTargetNode(node) ? node : null;
|
|
});
|
|
}
|
|
|
|
export function findTimelineNodeByLocatorId(locatorId) {
|
|
if (locatorId == null) return null;
|
|
const parts = String(locatorId).split(":");
|
|
if (parts.length !== 2 || Number.isFinite(Number(parts[0]))) return null;
|
|
const [graphId, localId] = parts;
|
|
return walkGraphs(rootGraph(), (node, graph) => {
|
|
return String(graph && graph.id) === graphId && String(node.id) === localId && isTargetNode(node) ? node : null;
|
|
});
|
|
}
|
|
|
|
export function findTimelineNodeByExecutionId(executionId) {
|
|
if (executionId == null) return null;
|
|
const normalizedId = String(executionId);
|
|
const parts = normalizedId.split(":").filter((part) => part !== "");
|
|
if (parts.length <= 1) return findTimelineNodeByLocalId(normalizedId);
|
|
|
|
for (const graph of uniqueGraphs(rootGraph(), activeCanvasGraph())) {
|
|
let currentGraph = graph;
|
|
let node = null;
|
|
for (let index = 0; index < parts.length; index += 1) {
|
|
node = findNodeInGraphByLocalId(currentGraph, parts[index]);
|
|
if (!node) break;
|
|
if (index === parts.length - 1) {
|
|
if (isTargetNode(node)) return node;
|
|
break;
|
|
}
|
|
currentGraph = node.subgraph;
|
|
if (!currentGraph) break;
|
|
}
|
|
}
|
|
|
|
return findTimelineNodeByLocalId(parts[parts.length - 1]);
|
|
}
|
|
|
|
export function timelinePayloadUiInstanceId(payload) {
|
|
if (payload == null) return null;
|
|
if (Array.isArray(payload)) payload = payload.length ? payload[0] : null;
|
|
if (typeof payload === "string") {
|
|
try {
|
|
payload = JSON.parse(payload);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (!payload || typeof payload !== "object") return null;
|
|
const value = valueFromCandidate(payload[UI_INSTANCE_WIDGET] || payload.uiInstanceId);
|
|
return value == null ? null : String(value);
|
|
}
|
|
|
|
export function nodeUiInstanceId(node) {
|
|
if (!node) return null;
|
|
const propertyValue = node.properties ? node.properties[UI_INSTANCE_PROPERTY] : null;
|
|
if (propertyValue != null) return String(propertyValue);
|
|
const widget = getWidget(node, UI_INSTANCE_WIDGET);
|
|
return widget && widget.value != null ? String(widget.value) : null;
|
|
}
|
|
|
|
export function findTimelineNodeByUiInstanceId(settingsPayload) {
|
|
const uiInstanceId = timelinePayloadUiInstanceId(settingsPayload);
|
|
if (!uiInstanceId) return null;
|
|
return walkGraphs(rootGraph(), (node) => {
|
|
return isTargetNode(node) && nodeUiInstanceId(node) === uiInstanceId ? node : null;
|
|
});
|
|
}
|
|
|
|
export function findTimelineNodeById(nodeId) {
|
|
if (nodeId == null) return null;
|
|
return findTimelineNodeByLocatorId(nodeId) || findTimelineNodeByExecutionId(nodeId);
|
|
}
|
|
|
|
export function applyBackendTimelineSettingsToNode(node, settingsPayload) {
|
|
if (!node || !isTargetNode(node)) return false;
|
|
if (!payloadsMatchNode(node, [settingsPayload])) return false;
|
|
setupNode(node);
|
|
const changed = applyBackendTimelineSettings(node, settingsPayload);
|
|
syncTimelineWidget(node);
|
|
node.setDirtyCanvas(true, true);
|
|
if (node.graph && typeof node.graph.setDirtyCanvas === "function") {
|
|
node.graph.setDirtyCanvas(true, true);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
export function deliverBackendTimelineSettingsToNode(node, settingsPayload) {
|
|
if (!node || !isTargetNode(node)) return false;
|
|
if (!payloadsMatchNode(node, [settingsPayload])) return false;
|
|
applyBackendTimelineSettingsToNode(node, settingsPayload);
|
|
return true;
|
|
}
|
|
|
|
export function installBackendTimelineSettingsListener() {
|
|
if (installBackendTimelineSettingsListener.installed) return;
|
|
installBackendTimelineSettingsListener.installed = true;
|
|
if (!api || typeof api.addEventListener !== "function") return;
|
|
api.addEventListener("etk_ltxv_timeline_settings", (event) => {
|
|
const detail = event && event.detail ? event.detail : {};
|
|
const candidates = [
|
|
findTimelineNodeById(detail.display_node),
|
|
findTimelineNodeById(detail.node),
|
|
findTimelineNodeByUiInstanceId(detail.settings),
|
|
];
|
|
const seen = new Set();
|
|
for (const node of candidates) {
|
|
if (!node || seen.has(node)) continue;
|
|
seen.add(node);
|
|
if (deliverBackendTimelineSettingsToNode(node, detail.settings)) return;
|
|
}
|
|
});
|
|
}
|