"""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, )