306 lines
12 KiB
Python
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,
|
|
)
|