feat: add external timeline nodes

This commit is contained in:
OpenClaw Agent
2026-07-16 21:05:27 +00:00
parent 4c0358c11b
commit 7b4e8865d6
4 changed files with 382 additions and 1 deletions

View File

@@ -11503,6 +11503,170 @@ app.registerExtension({
const decimals = Number.isInteger(options.decimals) ? options.decimals : 3;
return parseFloat(value.toFixed(decimals));
};
this._findLinkedOriginNode = function (targetNode, inputName) {
const graph = app.graph;
const visited = new Set();
const walkLink = (linkId) => {
if (!graph || linkId == null) return null;
if (visited.has(linkId)) return null;
visited.add(linkId);
const link = graph.links?.[linkId];
if (!link) return null;
const originNode = graph.getNodeById?.(link.origin_id);
if (!originNode) return null;
const comfyClass = originNode.comfyClass || originNode.type || "";
if (/reroute/i.test(comfyClass)) {
const upstreamLink = originNode.inputs?.[0]?.link;
if (upstreamLink != null) {
return walkLink(upstreamLink);
}
}
return originNode;
};
const input = targetNode?.inputs?.find(i => i.name === inputName);
return input?.link != null ? walkLink(input.link) : null;
};
this._readNodeWidgetValue = function (node, widgetName, fallbackIndex = 0) {
if (!Array.isArray(node?.widgets)) return null;
const namedWidget = node.widgets.find(w => w?.name === widgetName);
if (namedWidget?.value != null) return namedWidget.value;
const fallbackWidget = node.widgets[fallbackIndex];
return fallbackWidget?.value ?? null;
};
this._resolveNodeLinkedValue = function (targetNode, inputName, options = {}) {
const originNode = self._findLinkedOriginNode(targetNode, inputName);
if (!originNode) return null;
const value = self._readNodeWidgetValue(originNode, inputName, 0);
if (value == null || !Number.isFinite(Number(value))) return null;
if (options.integer) return Math.round(Number(value));
const decimals = Number.isInteger(options.decimals) ? options.decimals : 3;
return parseFloat(Number(value).toFixed(decimals));
};
this._extractLinkedImageSource = function (targetNode, inputName) {
const originNode = self._findLinkedOriginNode(targetNode, inputName);
if (!originNode) return null;
const filenameValue =
self._readNodeWidgetValue(originNode, "image") ??
self._readNodeWidgetValue(originNode, "filename") ??
self._readNodeWidgetValue(originNode, "file") ??
self._readNodeWidgetValue(originNode, "", 0);
if (typeof filenameValue !== "string" || !filenameValue.trim()) {
return null;
}
const subfolderValue =
self._readNodeWidgetValue(originNode, "subfolder") ??
self._readNodeWidgetValue(originNode, "folder");
const filename = filenameValue.trim();
const subfolder = typeof subfolderValue === "string" ? subfolderValue.trim() : "";
const imageFile = subfolder ? `${subfolder}/${filename}` : filename;
const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input&subfolder=${encodeURIComponent(subfolder)}`);
return { imageFile, imageB64: imageUrl };
};
this._buildExternalTimelinePayload = function () {
const timelineNode = self._findLinkedOriginNode(self, "timeline");
const comfyClass = timelineNode?.comfyClass || timelineNode?.type || "";
if (!timelineNode || comfyClass !== "TimelineCS") {
return null;
}
const rawDuration =
self._resolveNodeLinkedValue(timelineNode, "duration", { decimals: 3 }) ??
parseFloat(self._readNodeWidgetValue(timelineNode, "duration") || 0);
const duration = Math.max(0.1, Number.isFinite(rawDuration) ? rawDuration : 5.0);
const sectionSetNode = self._findLinkedOriginNode(timelineNode, "section_set");
const setClass = sectionSetNode?.comfyClass || sectionSetNode?.type || "";
const sections = [];
if (sectionSetNode && setClass === "TimelineSectionSetCS") {
for (let i = 1; i <= 5; i++) {
const sectionNode = self._findLinkedOriginNode(sectionSetNode, `section_${i}`);
const sectionClass = sectionNode?.comfyClass || sectionNode?.type || "";
if (!sectionNode || sectionClass !== "TimelineSectionCS") continue;
const text = String(self._readNodeWidgetValue(sectionNode, "text") || "").trim();
if (!text) continue;
const rawSectionDuration =
self._resolveNodeLinkedValue(sectionNode, "duration", { decimals: 3 }) ??
parseFloat(self._readNodeWidgetValue(sectionNode, "duration") || 0);
const sectionDuration = Math.max(0.1, Number.isFinite(rawSectionDuration) ? rawSectionDuration : 1.0);
const imageSource = self._extractLinkedImageSource(sectionNode, "image");
sections.push({
text,
duration: sectionDuration,
imageFile: imageSource?.imageFile || "",
imageB64: imageSource?.imageB64 || "",
});
}
}
return { duration, sections };
};
this._syncTimelineFromLink = function () {
const payload = self._buildExternalTimelinePayload();
const snapshot = JSON.stringify(payload || null);
if (snapshot === self._lastLinkedTimelineSnapshot) {
return;
}
self._lastLinkedTimelineSnapshot = snapshot;
if (!payload || !self._timelineEditor) {
return;
}
const editor = self._timelineEditor;
const frameRate = editor.getFrameRate ? editor.getFrameRate() : (parseFloat(self.widgets?.find(w => w.name === "frame_rate")?.value) || 24);
let currentStart = 0;
const builtSegments = [];
for (let i = 0; i < payload.sections.length; i++) {
const section = payload.sections[i];
const segLength = Math.max(1, Math.round(section.duration * frameRate));
const seg = {
id: `linked_section_${i}`,
start: currentStart,
length: segLength,
prompt: section.text,
type: section.imageFile ? "image" : "text",
};
if (section.imageFile) {
seg.imageFile = section.imageFile;
seg.imageB64 = section.imageB64;
}
builtSegments.push(seg);
currentStart += segLength;
}
const effectiveDuration = Math.max(payload.duration, currentStart / Math.max(frameRate, 1));
const durationWidget = self.widgets?.find(w => w.name === "duration_seconds");
if (durationWidget && durationWidget.value !== effectiveDuration) {
durationWidget.value = effectiveDuration;
if (durationWidget.callback) {
try { durationWidget.callback(effectiveDuration); } catch (e) {}
}
}
editor.timeline.segments = builtSegments;
editor.selectionType = "image";
editor.selectedIndex = builtSegments.length ? 0 : -1;
editor.loadMedia();
editor.updateUIFromSelection();
editor.commitChanges();
editor.render();
if (self._ltxSettingsRefresh) {
try { self._ltxSettingsRefresh(); } catch (e) {}
}
if (self.setDirtyCanvas) {
self.setDirtyCanvas(true, true);
}
};
this._syncGlobalPromptFromLink = function () {
const globalInput = self.inputs?.find(i => i.name === "global_prompt");
if (globalInput && globalInput.link !== null && globalInput.link !== undefined) {
@@ -11611,6 +11775,7 @@ app.registerExtension({
self._syncGlobalPromptFromLink();
self._syncTimingFromLinks();
self._syncResolutionFromLinks?.();
self._syncTimelineFromLink?.();
};
const origOnDrawForeground = this.onDrawForeground;
@@ -11621,6 +11786,7 @@ app.registerExtension({
self._syncGlobalPromptFromLink();
self._syncTimingFromLinks();
self._syncResolutionFromLinks?.();
self._syncTimelineFromLink?.();
};
// --- LTX Director settings panel (Stage 1: Resolution | Timing/Reference) ---