Files
2026-08-26 09:55:12 -06:00

306 lines
12 KiB
Python

"""Standalone LTXV timeline image-editor ComfyUI node."""
import math
from .images import (
_etk_scalar_input,
_etk_send_timeline_settings_to_ui,
_etk_timeline_editor_compat_inputs,
_etk_timeline_result_with_ui,
_etk_timeline_with_connected_images,
_etk_unwrap_single_input,
)
from .rendering import (
_etk_timeline_image_batch,
_etk_timeline_unedited_source_batch,
_fit_etk_timeline_keyframe_image,
_load_etk_timeline_keyframe_unedited_sources,
)
from .schema import _parse_etk_ltxv_timeline
def _etk_keyframe_latent_strength(keyframe, global_strength):
latent_strength = keyframe.get("latent_strength", None)
if latent_strength is None:
latent_strength = global_strength
try:
latent_strength = float(latent_strength)
except (TypeError, ValueError):
latent_strength = float(global_strength)
return max(0.0, min(1.0, latent_strength))
def _etk_timeline_list_input(name, value):
"""Unwrap one Comfy list-transport layer without collapsing singleton lists."""
if value is None:
return None
if not isinstance(value, (list, tuple)):
raise ValueError(f"{name} must be a LIST")
values = list(value)
if len(values) == 1 and isinstance(values[0], (list, tuple)):
values = list(values[0])
return values
def _etk_timeline_time_fields(value, index, fps, latent_frames):
try:
time_seconds = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"times[{index}] must be a number of seconds") from exc
if not math.isfinite(time_seconds) or time_seconds < 0.0:
raise ValueError(f"times[{index}] must be a finite, non-negative number of seconds")
frame = int(round(time_seconds * fps))
max_frame = max(0, (int(latent_frames) - 1) * 8)
if frame > max_frame:
raise ValueError(
f"times[{index}]={time_seconds} seconds maps to frame {frame}, "
f"outside video frame range 0..{max_frame}"
)
return time_seconds, frame, int((frame / 8.0) + 0.5)
def _etk_empty_timeline_keyframe(slot, fps):
frame = int(slot) * 8
return {
"frame": frame,
"slot": int(slot),
"time": frame / fps,
"fit_mode": "crop",
"pan_x": 0.0,
"pan_y": 0.0,
"zoom": 1.0,
"brightness": 1.0,
"contrast": 1.0,
"image": None,
"layers": [],
"selected_layer_id": "",
"input_image": False,
"positive_prompt": "",
"negative_prompt": "",
"prompt_strength": 1.0,
"latent_strength": None,
}
def _etk_apply_timeline_list_inputs(keyframes, prompts, strengths, times, fps, latent_frames):
supplied = {
"prompts": _etk_timeline_list_input("prompts", prompts),
"strengths": _etk_timeline_list_input("strengths", strengths),
"times": _etk_timeline_list_input("times", times),
}
provided = {name: values for name, values in supplied.items() if values is not None}
if not provided:
return keyframes, None
lengths = {name: len(values) for name, values in provided.items()}
if len(set(lengths.values())) != 1:
details = ", ".join(f"{name}={count}" for name, count in lengths.items())
raise ValueError(f"timeline list inputs must have equal lengths; got {details}")
item_count = next(iter(lengths.values()))
merged = [dict(keyframe) for keyframe in keyframes]
if len(merged) > item_count:
raise ValueError(
f"timeline list inputs contain {item_count} items, but timeline_json contains "
f"{len(merged)} keyframes"
)
occupied_slots = {int(keyframe["slot"]) for keyframe in merged}
cursor = max(occupied_slots, default=-1) + 1
while len(merged) < item_count:
index = len(merged)
if supplied["times"] is not None:
time_seconds, frame, slot = _etk_timeline_time_fields(
supplied["times"][index], index, fps, latent_frames
)
keyframe = _etk_empty_timeline_keyframe(slot, fps)
keyframe.update({"time": time_seconds, "frame": frame, "slot": slot})
else:
while cursor in occupied_slots:
cursor += 1
if cursor >= latent_frames:
raise ValueError(
f"timeline list inputs require {item_count} keyframes, but no free latent "
f"slot remains within 0..{latent_frames - 1}"
)
keyframe = _etk_empty_timeline_keyframe(cursor, fps)
cursor += 1
occupied_slots.add(int(keyframe["slot"]))
merged.append(keyframe)
for index, keyframe in enumerate(merged):
if supplied["prompts"] is not None:
prompt = supplied["prompts"][index]
if isinstance(prompt, (list, tuple, dict)):
raise ValueError(f"prompts[{index}] must be a string value")
keyframe["positive_prompt"] = str(prompt if prompt is not None else "")
if supplied["strengths"] is not None:
try:
strength = float(supplied["strengths"][index])
except (TypeError, ValueError) as exc:
raise ValueError(f"strengths[{index}] must be a number") from exc
if not math.isfinite(strength):
raise ValueError(f"strengths[{index}] must be finite")
keyframe["latent_strength"] = max(0.0, min(1.0, strength))
if supplied["times"] is not None:
time_seconds, frame, slot = _etk_timeline_time_fields(
supplied["times"][index], index, fps, latent_frames
)
keyframe.update({"time": time_seconds, "frame": frame, "slot": slot})
return sorted(merged, key=lambda item: (item["slot"], item["frame"])), item_count
class ETKLTXVTimelineImageEditor:
DISPLAY_NAME = "ETK LTXV Timeline Image Editor"
DESCRIPTION = (
"Standalone LTXV/LTX 2.x image timeline editor. It uses the same "
"timeline image-editing UI as the all-in-one PromptRelay node, then "
"emits composited preview images, matching unedited source images, "
"per-keyframe guide strengths, frame indexes, and normalized positive "
"prompts without loading a model, CLIP, or VAE. Optional prompt, "
"strength, and time lists replace keyframe metadata by index."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {"default": 768, "min": 64, "max": 8192, "step": 32}),
"height": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 32}),
"length": ("INT", {"default": 97, "min": 1, "max": 8192, "step": 8}),
"timeline_json": ("STRING", {"default": "[]", "multiline": True}),
"fps": ("FLOAT", {"default": 25.0, "min": 0.001, "max": 240.0, "step": 0.01}),
},
"optional": {
"images": ("IMAGE",),
"ui_instance_id": ("STRING", {"default": "", "multiline": False}),
"prompts": ("LIST", {
"tooltip": "Positive prompts aligned one-to-one with timeline keyframes/images.",
}),
"strengths": ("LIST", {
"tooltip": "Image guide strengths aligned one-to-one with timeline keyframes/images.",
}),
"times": ("LIST", {
"tooltip": "Keyframe times in seconds, aligned one-to-one with timeline keyframes/images.",
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("IMAGE", "IMAGE", "INT", "INT", "INT", "LIST", "LIST", "LIST", "LIST")
RETURN_NAMES = (
"edited_images",
"unedited_images",
"width",
"height",
"length",
"strengths",
"frame_indexes",
"position_seconds",
"prompts",
)
INPUT_IS_LIST = True
OUTPUT_IS_LIST = (False, False, False, False, False, False, False, False, False)
FUNCTION = "generate"
CATEGORY = "ETK/Image"
def generate(
self,
width,
height,
length,
timeline_json="[]",
fps=25.0,
strength=1.0,
images=None,
ui_instance_id="",
unique_id=None,
prompts=None,
strengths=None,
times=None,
):
width = _etk_scalar_input(width)
height = _etk_scalar_input(height)
length = _etk_scalar_input(length)
timeline_json = _etk_scalar_input(timeline_json)
fps = _etk_scalar_input(fps)
strength = _etk_scalar_input(strength)
images = _etk_unwrap_single_input(images)
ui_instance_id = _etk_scalar_input(ui_instance_id)
unique_id = _etk_scalar_input(unique_id)
width = max(64, int(round(int(width) / 32)) * 32)
height = max(64, int(round(int(height) / 32)) * 32)
length = max(1, int(length))
if (length - 1) % 8 != 0:
raise ValueError("LTXV video length must be 1 plus a multiple of 8")
try:
fps = max(0.001, float(fps))
except (TypeError, ValueError) as exc:
raise ValueError("timeline fps must be a number") from exc
timeline_json, strength = _etk_timeline_editor_compat_inputs(timeline_json, strength)
latent_frames = ((length - 1) // 8) + 1
keyframes, list_item_count = _etk_apply_timeline_list_inputs(
_parse_etk_ltxv_timeline(timeline_json, include_empty=True, fps=fps),
prompts,
strengths,
times,
fps,
latent_frames,
)
keyframes = _etk_timeline_with_connected_images(
keyframes,
images,
latent_frames,
)
if list_item_count is not None and len(keyframes) != list_item_count:
raise ValueError(
f"timeline list inputs contain {list_item_count} items, but timeline/images "
f"contain {len(keyframes)} keyframes"
)
fitted_images = []
unedited_images = []
output_strengths = []
frame_indexes = []
position_seconds = []
output_prompts = []
for keyframe in keyframes:
fitted = _fit_etk_timeline_keyframe_image(keyframe, width, height)
if fitted is not None:
fitted_images.append(fitted.copy())
for unedited in _load_etk_timeline_keyframe_unedited_sources(keyframe, width, height):
unedited_images.append(unedited.copy())
output_strengths.append(_etk_keyframe_latent_strength(keyframe, strength))
frame_index = int(keyframe["frame"])
frame_indexes.append(frame_index)
position_seconds.append(frame_index / fps)
output_prompts.append(str(keyframe.get("positive_prompt", "") or ""))
settings = {"width": width, "height": height, "length": length, "fps": fps}
_etk_send_timeline_settings_to_ui(unique_id, settings, ui_instance_id=ui_instance_id)
result = (
_etk_timeline_image_batch(fitted_images, width, height),
_etk_timeline_unedited_source_batch(unedited_images, width, height),
width,
height,
length,
output_strengths,
frame_indexes,
position_seconds,
output_prompts,
)
return _etk_timeline_result_with_ui(
result,
keyframes,
force_ui=images is not None or list_item_count is not None,
settings=settings,
ui_instance_id=ui_instance_id,
)