feat: add external timeline nodes
This commit is contained in:
10
__init__.py
10
__init__.py
@@ -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",
|
||||||
|
|||||||
@@ -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) ---
|
||||||
|
|||||||
@@ -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
136
timeline_nodes.py
Normal 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)
|
||||||
Reference in New Issue
Block a user