Files
ComfyUI_ETK_LTXV_Timeline_E…/web/ltxv_timeline/runtime.js
T
2026-08-26 09:55:12 -06:00

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