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

@@ -1,6 +1,7 @@
from .ltx_director import LTXDirector from .ltx_director import LTXDirector
from .ltx_director_guide import LTXDirectorGuide, LTXDirectorCropGuides from .ltx_director_guide import LTXDirectorGuide, LTXDirectorCropGuides
from .msr_character import MSRCharacter, MSRCharacterSet from .msr_character import MSRCharacter, MSRCharacterSet
from .timeline_nodes import TimelineSection, TimelineSectionSet, Timeline
from comfy_api.latest import ComfyExtension, io from comfy_api.latest import ComfyExtension, io
from typing_extensions import override from typing_extensions import override
from .latent_slice import CleanLatentSlice from .latent_slice import CleanLatentSlice
@@ -11,6 +12,9 @@ class PromptRelay(ComfyExtension):
return [ return [
MSRCharacter, MSRCharacter,
MSRCharacterSet, MSRCharacterSet,
TimelineSection,
TimelineSectionSet,
Timeline,
LTXDirector, LTXDirector,
] ]
@@ -21,6 +25,9 @@ NODE_CLASS_MAPPINGS = {
"LTXDirectorCS": LTXDirector, "LTXDirectorCS": LTXDirector,
"MSRCharacterCS": MSRCharacter, "MSRCharacterCS": MSRCharacter,
"MSRCharacterSetCS": MSRCharacterSet, "MSRCharacterSetCS": MSRCharacterSet,
"TimelineSectionCS": TimelineSection,
"TimelineSectionSetCS": TimelineSectionSet,
"TimelineCS": Timeline,
"LTXDirectorGuideCS": LTXDirectorGuide, "LTXDirectorGuideCS": LTXDirectorGuide,
"LTXDirectorCropGuidesCS": LTXDirectorCropGuides, "LTXDirectorCropGuidesCS": LTXDirectorCropGuides,
"CleanLatentSliceCS": CleanLatentSlice, "CleanLatentSliceCS": CleanLatentSlice,
@@ -30,6 +37,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"LTXDirectorCS": "LTX Director DUMAS", "LTXDirectorCS": "LTX Director DUMAS",
"MSRCharacterCS": "MSR Character DUMAS", "MSRCharacterCS": "MSR Character DUMAS",
"MSRCharacterSetCS": "MSR Character Set DUMAS", "MSRCharacterSetCS": "MSR Character Set DUMAS",
"TimelineSectionCS": "Timeline Section DUMAS",
"TimelineSectionSetCS": "Timeline Section Set DUMAS",
"TimelineCS": "Timeline DUMAS",
"LTXDirectorGuideCS": "LTX Director Guide DUMAS", "LTXDirectorGuideCS": "LTX Director Guide DUMAS",
"LTXDirectorCropGuidesCS": "LTX Director Crop Guides DUMAS", "LTXDirectorCropGuidesCS": "LTX Director Crop Guides DUMAS",
"CleanLatentSliceCS": "Clean Latent Slice DUMAS", "CleanLatentSliceCS": "Clean Latent Slice DUMAS",

View File

@@ -11503,6 +11503,170 @@ app.registerExtension({
const decimals = Number.isInteger(options.decimals) ? options.decimals : 3; const decimals = Number.isInteger(options.decimals) ? options.decimals : 3;
return parseFloat(value.toFixed(decimals)); 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 () { this._syncGlobalPromptFromLink = function () {
const globalInput = self.inputs?.find(i => i.name === "global_prompt"); const globalInput = self.inputs?.find(i => i.name === "global_prompt");
if (globalInput && globalInput.link !== null && globalInput.link !== undefined) { if (globalInput && globalInput.link !== null && globalInput.link !== undefined) {
@@ -11611,6 +11775,7 @@ app.registerExtension({
self._syncGlobalPromptFromLink(); self._syncGlobalPromptFromLink();
self._syncTimingFromLinks(); self._syncTimingFromLinks();
self._syncResolutionFromLinks?.(); self._syncResolutionFromLinks?.();
self._syncTimelineFromLink?.();
}; };
const origOnDrawForeground = this.onDrawForeground; const origOnDrawForeground = this.onDrawForeground;
@@ -11621,6 +11786,7 @@ app.registerExtension({
self._syncGlobalPromptFromLink(); self._syncGlobalPromptFromLink();
self._syncTimingFromLinks(); self._syncTimingFromLinks();
self._syncResolutionFromLinks?.(); self._syncResolutionFromLinks?.();
self._syncTimelineFromLink?.();
}; };
// --- LTX Director settings panel (Stage 1: Resolution | Timing/Reference) --- // --- LTX Director settings panel (Stage 1: Resolution | Timing/Reference) ---

View File

@@ -33,6 +33,7 @@ from .prompt_relay import (
from .patches import detect_model_type, apply_patches from .patches import detect_model_type, apply_patches
from .msr_character import MSRCharacterSetData from .msr_character import MSRCharacterSetData
from .timeline_nodes import TimelineData
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -166,6 +167,47 @@ def _load_image_reference(b64_or_url: str = "", filename: str = None, cache: dic
return _empty_image_tensor() return _empty_image_tensor()
def _build_runtime_timeline_payload(external_timeline, frame_rate: float) -> tuple[dict, str, str, float, int]:
tdata = {
"segments": [],
"motionSegments": [],
"audioSegments": [],
}
if not isinstance(external_timeline, dict):
return tdata, "", "", 0.0, 0
sections = external_timeline.get("sections") or []
duration_seconds = max(0.1, float(external_timeline.get("duration") or 0.0))
duration_frames = max(1, int(round(duration_seconds * frame_rate)))
current_start = 0
local_prompts = []
segment_lengths = []
for index, section in enumerate(sections):
if not isinstance(section, dict):
continue
prompt = (section.get("text") or "").strip()
if not prompt:
continue
section_seconds = max(0.1, float(section.get("duration") or 0.0))
section_frames = max(1, int(round(section_seconds * frame_rate)))
seg = {
"id": f"external_{index}",
"start": current_start,
"length": section_frames,
"prompt": prompt,
"type": "image" if section.get("image") is not None else "text",
}
if section.get("image") is not None:
seg["runtime_image"] = section.get("image")
tdata["segments"].append(seg)
local_prompts.append(prompt)
segment_lengths.append(str(section_frames))
current_start += section_frames
return tdata, " | ".join(local_prompts), ",".join(segment_lengths), duration_seconds, duration_frames
def _compute_signal_peaks(samples: np.ndarray, num_peaks: int = PEAK_BUCKETS) -> list[float]: def _compute_signal_peaks(samples: np.ndarray, num_peaks: int = PEAK_BUCKETS) -> list[float]:
if samples.size == 0: if samples.size == 0:
return [0.0] * num_peaks return [0.0] * num_peaks
@@ -1048,6 +1090,8 @@ async def ltx_director_upload_chunk(request):
def _load_image_tensor(seg: dict, cache: dict | None = None, input_dir: str = None) -> torch.Tensor: def _load_image_tensor(seg: dict, cache: dict | None = None, input_dir: str = None) -> torch.Tensor:
"""Decode an image from the ComfyUI input folder (if imageFile provided) or fallback to base64 """Decode an image from the ComfyUI input folder (if imageFile provided) or fallback to base64
to a ComfyUI-style image tensor of shape [1, H, W, 3], float32 in [0, 1].""" to a ComfyUI-style image tensor of shape [1, H, W, 3], float32 in [0, 1]."""
if seg.get("runtime_image") is not None:
return seg.get("runtime_image")
return _load_image_reference( return _load_image_reference(
b64_or_url=seg.get("imageB64", ""), b64_or_url=seg.get("imageB64", ""),
filename=seg.get("imageFile"), filename=seg.get("imageFile"),
@@ -2023,6 +2067,11 @@ class LTXDirector(io.ComfyNode):
optional=True, optional=True,
tooltip="Optional external MSR Character Set. Replaces the legacy inline character panel in the director UI.", tooltip="Optional external MSR Character Set. Replaces the legacy inline character panel in the director UI.",
), ),
TimelineData.Input(
"timeline",
optional=True,
tooltip="Optional external Timeline DUMAS payload. When connected, it populates the main timeline track and duration.",
),
io.Float.Input( io.Float.Input(
"start", force_input=True, optional=True, default=0.0, "start", force_input=True, optional=True, default=0.0,
tooltip="Automation (connection-only). Start time in SECONDS. Overrides the panel Start when connected.", tooltip="Automation (connection-only). Start time in SECONDS. Overrides the panel Start when connected.",
@@ -2058,7 +2107,7 @@ class LTXDirector(io.ComfyNode):
custom_width=768, custom_height=512, resize_method="maintain aspect ratio", custom_width=768, custom_height=512, resize_method="maintain aspect ratio",
divisible_by=32, img_compression=0, audio_vae=None, optional_latent=None, divisible_by=32, img_compression=0, audio_vae=None, optional_latent=None,
use_custom_audio=False, inpaint_audio=True, use_custom_motion=True, override_audio=False, use_custom_audio=False, inpaint_audio=True, use_custom_motion=True, override_audio=False,
vae=None, reference_strength=1.0, ref_images=None, character_set=None, vae=None, reference_strength=1.0, ref_images=None, character_set=None, timeline=None,
start=None, end=None, duration=None) -> io.NodeOutput: start=None, end=None, duration=None) -> io.NodeOutput:
input_dir = folder_paths.get_input_directory() input_dir = folder_paths.get_input_directory()
image_cache = {} image_cache = {}
@@ -2075,6 +2124,26 @@ class LTXDirector(io.ComfyNode):
processed_image_cache[cache_key] = processed processed_image_cache[cache_key] = processed
return processed return processed
if timeline is not None:
external_tdata, external_local_prompts, external_segment_lengths, external_duration_seconds, external_duration_frames = _build_runtime_timeline_payload(
timeline,
float(frame_rate) if frame_rate else 24.0,
)
try:
existing_tdata = _safe_json_loads(timeline_data)
except Exception:
existing_tdata = {}
merged_tdata = dict(existing_tdata or {})
merged_tdata["segments"] = external_tdata["segments"]
merged_tdata.setdefault("motionSegments", existing_tdata.get("motionSegments", []))
merged_tdata.setdefault("audioSegments", existing_tdata.get("audioSegments", []))
timeline_data = json.dumps(merged_tdata)
local_prompts = external_local_prompts
segment_lengths = external_segment_lengths
if duration is None:
duration_seconds = external_duration_seconds
duration_frames = external_duration_frames
# Parse timeline data # Parse timeline data
try: try:
tdata = _safe_json_loads(timeline_data) tdata = _safe_json_loads(timeline_data)

136
timeline_nodes.py Normal file
View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from comfy_api.latest import io
TimelineSectionData = io.Custom("TIMELINE_SECTION")
TimelineSectionSetData = io.Custom("TIMELINE_SECTION_SET")
TimelineData = io.Custom("TIMELINE")
class TimelineSection(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="TimelineSectionCS",
display_name="Timeline Section DUMAS",
category="WhatDreamsCost DUMAS",
description="Reusable timeline section with required text, optional image, and a duration in seconds.",
inputs=[
io.Image.Input(
"image",
optional=True,
tooltip="Optional image for this timeline section. When omitted, the section becomes a text-only block.",
),
io.String.Input(
"text",
multiline=True,
default="",
tooltip="Required text/prompt for this timeline section.",
),
io.Float.Input(
"duration",
default=1.0,
min=0.1,
max=1000.0,
step=0.01,
tooltip="Section duration in seconds.",
),
],
outputs=[
TimelineSectionData.Output(display_name="section"),
],
)
@classmethod
def execute(cls, image=None, text="", duration=1.0) -> io.NodeOutput:
payload = {
"image": image,
"text": (text or "").strip(),
"duration": max(0.1, float(duration or 0.0)),
}
return io.NodeOutput(payload)
class TimelineSectionSet(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="TimelineSectionSetCS",
display_name="Timeline Section Set DUMAS",
category="WhatDreamsCost DUMAS",
description="Bundles up to five Timeline Section nodes into one reusable section set.",
inputs=[
TimelineSectionData.Input("section_1", optional=True),
TimelineSectionData.Input("section_2", optional=True),
TimelineSectionData.Input("section_3", optional=True),
TimelineSectionData.Input("section_4", optional=True),
TimelineSectionData.Input("section_5", optional=True),
],
outputs=[
TimelineSectionSetData.Output(display_name="section_set"),
],
)
@classmethod
def execute(
cls,
section_1=None,
section_2=None,
section_3=None,
section_4=None,
section_5=None,
) -> io.NodeOutput:
sections = []
for item in (section_1, section_2, section_3, section_4, section_5):
if not item:
continue
text = (item.get("text") or "").strip()
if not text:
continue
sections.append(
{
"image": item.get("image"),
"text": text,
"duration": max(0.1, float(item.get("duration") or 0.0)),
}
)
return io.NodeOutput({"sections": sections})
class Timeline(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="TimelineCS",
display_name="Timeline DUMAS",
category="WhatDreamsCost DUMAS",
description="Builds an external timeline payload for LTX Director from a section set and total duration.",
inputs=[
TimelineSectionSetData.Input(
"section_set",
optional=True,
tooltip="Optional section set. When omitted, the timeline payload contains no sections.",
),
io.Float.Input(
"duration",
default=5.0,
min=0.1,
max=1000.0,
step=0.01,
tooltip="Total timeline duration in seconds.",
),
],
outputs=[
TimelineData.Output(display_name="timeline"),
],
)
@classmethod
def execute(cls, section_set=None, duration=5.0) -> io.NodeOutput:
payload = {
"duration": max(0.1, float(duration or 0.0)),
"sections": list((section_set or {}).get("sections") or []),
}
return io.NodeOutput(payload)