// 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; } }); }