Release standalone timeline editor 1.0.0
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user