Seashellsen commited on
Commit
9fc1e29
·
verified ·
1 Parent(s): 79994bd

Upload 2 files

Browse files
Files changed (2) hide show
  1. ltx_director.js +0 -0
  2. ltx_director.py +661 -0
ltx_director.js ADDED
The diff for this file is too large to render. See raw diff
 
ltx_director.py ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import json
3
+ import base64
4
+ import io as _io
5
+ import math
6
+
7
+ import numpy as np
8
+ import torch
9
+ import av
10
+ from PIL import Image
11
+
12
+ import os
13
+ import folder_paths
14
+ import comfy.model_management
15
+
16
+ from comfy_api.latest import io
17
+
18
+ from .prompt_relay import (
19
+ get_raw_tokenizer,
20
+ map_token_indices,
21
+ build_segments,
22
+ create_mask_fn,
23
+ distribute_segment_lengths,
24
+ )
25
+
26
+ from .patches import detect_model_type, apply_patches
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ # Custom socket type shared with LTXSequencer
31
+ GuideData = io.Custom("GUIDE_DATA")
32
+
33
+
34
+ def _load_image_tensor(seg: dict) -> torch.Tensor:
35
+ """Decode an image from the ComfyUI input folder (if imageFile provided) or fallback to base64
36
+ to a ComfyUI-style image tensor of shape [1, H, W, 3], float32 in [0, 1]."""
37
+ if seg.get("imageFile"):
38
+ file_path = os.path.join(folder_paths.get_input_directory(), seg["imageFile"])
39
+ if os.path.exists(file_path):
40
+ img = Image.open(file_path).convert("RGB")
41
+ arr = np.array(img, dtype=np.float32) / 255.0
42
+ return torch.from_numpy(arr).unsqueeze(0)
43
+
44
+ b64_str = seg.get("imageB64", "")
45
+ if not b64_str or b64_str.startswith("/view?"):
46
+ return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
47
+
48
+ if "," in b64_str:
49
+ b64_str = b64_str.split(",", 1)[1]
50
+
51
+ try:
52
+ img_bytes = base64.b64decode(b64_str)
53
+ img = Image.open(_io.BytesIO(img_bytes)).convert("RGB")
54
+ arr = np.array(img, dtype=np.float32) / 255.0
55
+ return torch.from_numpy(arr).unsqueeze(0)
56
+ except:
57
+ return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
58
+
59
+
60
+ def _resize_image(tensor: torch.Tensor, target_w: int, target_h: int, method: str, divisible_by: int) -> torch.Tensor:
61
+ """Resize a [1, H, W, 3] float32 tensor to target dimensions using the given method,
62
+ then snap the final dimensions to be divisible by `divisible_by`."""
63
+ from PIL import Image as _PilImage
64
+ import torchvision.transforms.functional as TF
65
+
66
+ def snap(val, div):
67
+ return max(div, (val // div) * div)
68
+
69
+ tw = snap(target_w, divisible_by)
70
+ th = snap(target_h, divisible_by)
71
+
72
+ img_np = (tensor[0].cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
73
+ pil = _PilImage.fromarray(img_np)
74
+ src_w, src_h = pil.size
75
+
76
+ if method == "stretch to fit":
77
+ resized = pil.resize((tw, th), _PilImage.LANCZOS)
78
+
79
+ elif method == "maintain aspect ratio":
80
+ ratio = min(tw / src_w, th / src_h)
81
+ new_w = int(src_w * ratio)
82
+ new_h = int(src_h * ratio)
83
+ new_w = snap(new_w, divisible_by)
84
+ new_h = snap(new_h, divisible_by)
85
+ resized = pil.resize((new_w, new_h), _PilImage.LANCZOS)
86
+
87
+ elif method == "pad":
88
+ ratio = min(tw / src_w, th / src_h)
89
+ new_w = snap(int(src_w * ratio), divisible_by)
90
+ new_h = snap(int(src_h * ratio), divisible_by)
91
+ inner = pil.resize((new_w, new_h), _PilImage.LANCZOS)
92
+ resized = _PilImage.new("RGB", (tw, th), (0, 0, 0))
93
+ resized.paste(inner, ((tw - new_w) // 2, (th - new_h) // 2))
94
+
95
+ elif method == "crop":
96
+ ratio = max(tw / src_w, th / src_h)
97
+ new_w = int(src_w * ratio)
98
+ new_h = int(src_h * ratio)
99
+ inner = pil.resize((new_w, new_h), _PilImage.LANCZOS)
100
+ left = (new_w - tw) // 2
101
+ top = (new_h - th) // 2
102
+ resized = inner.crop((left, top, left + tw, top + th))
103
+
104
+ else:
105
+ resized = pil.resize((tw, th), _PilImage.LANCZOS)
106
+
107
+ arr = np.array(resized, dtype=np.float32) / 255.0
108
+ return torch.from_numpy(arr).unsqueeze(0)
109
+
110
+
111
+ def _compress_image(tensor: torch.Tensor, crf: int) -> torch.Tensor:
112
+ """Apply H.264 compression artefacts to a [1, H, W, 3] float32 tensor (ComfyUI image format).
113
+ crf=0 means no compression. Uses PyAV to encode/decode a single frame in-memory."""
114
+ if crf == 0:
115
+ return tensor
116
+ img = tensor[0] # [H, W, 3]
117
+ # Dimensions must be even for H.264
118
+ h = (img.shape[0] // 2) * 2
119
+ w = (img.shape[1] // 2) * 2
120
+ img_np = (img[:h, :w] * 255.0).byte().cpu().numpy() # uint8 [H, W, 3]
121
+
122
+ try:
123
+ buf = _io.BytesIO()
124
+ container = av.open(buf, mode="w", format="mp4")
125
+ stream = container.add_stream("libx264", rate=1)
126
+ stream.width = w
127
+ stream.height = h
128
+ stream.pix_fmt = "yuv420p"
129
+ stream.options = {"crf": str(crf), "preset": "ultrafast"}
130
+ frame = av.VideoFrame.from_ndarray(img_np, format="rgb24")
131
+ for pkt in stream.encode(frame):
132
+ container.mux(pkt)
133
+ for pkt in stream.encode(None):
134
+ container.mux(pkt)
135
+ container.close()
136
+
137
+ buf.seek(0)
138
+ container_r = av.open(buf, mode="r")
139
+ decoded = None
140
+ for frame_r in container_r.decode(video=0):
141
+ decoded = frame_r.to_ndarray(format="rgb24") # [H, W, 3]
142
+ break
143
+ container_r.close()
144
+
145
+ if decoded is None:
146
+ return tensor
147
+ arr = torch.from_numpy(decoded.astype(np.float32) / 255.0).to(tensor.device, tensor.dtype)
148
+ # Re-embed into original tensor shape (may have been cropped by even-rounding)
149
+ out = tensor.clone()
150
+ out[0, :h, :w] = arr
151
+ return out
152
+ except Exception as e:
153
+ log.warning("[PromptRelay] img_compression encode/decode failed: %s", e)
154
+ return tensor
155
+
156
+
157
+ def _build_combined_audio(timeline_data_str: str, duration_frames: int, frame_rate: float) -> dict:
158
+ """Parses timeline JSON, loads/trims audio directly from memory using PyAV,
159
+ and aligns to a global timeline yielding ComfyUI's format.
160
+ Output length explicitly mimics the timeline's duration_frames length."""
161
+ target_sr = 44100
162
+ total_samples = max(1, int(math.ceil(duration_frames / frame_rate * target_sr)))
163
+ empty_audio = {"waveform": torch.zeros((1, 2, total_samples), dtype=torch.float32), "sample_rate": target_sr}
164
+
165
+ if not timeline_data_str:
166
+ return empty_audio
167
+
168
+ try:
169
+ data = json.loads(timeline_data_str)
170
+ audio_segs = data.get("audioSegments", [])
171
+ except Exception:
172
+ return empty_audio
173
+
174
+ if not audio_segs:
175
+ return empty_audio
176
+
177
+ out_waveform = torch.zeros((2, total_samples), dtype=torch.float32)
178
+
179
+ for seg in audio_segs:
180
+ buffer = None
181
+ if seg.get("audioFile"):
182
+ file_path = os.path.join(folder_paths.get_input_directory(), seg["audioFile"])
183
+ if os.path.exists(file_path):
184
+ with open(file_path, "rb") as f:
185
+ buffer = _io.BytesIO(f.read())
186
+
187
+ if not buffer and seg.get("audioB64"):
188
+ b64 = seg.get("audioB64")
189
+ if "," in b64:
190
+ b64 = b64.split(",", 1)[1]
191
+ try:
192
+ audio_bytes = base64.b64decode(b64)
193
+ buffer = _io.BytesIO(audio_bytes)
194
+ except:
195
+ pass
196
+
197
+ if not buffer:
198
+ continue
199
+
200
+ try:
201
+ clip_frames = []
202
+
203
+ # Use PyAV to decode directly from memory buffer
204
+ with av.open(buffer) as container:
205
+ stream = container.streams.audio[0]
206
+
207
+ # Setup resampler to ensure output is 44.1kHz, Stereo, Float32 Planar
208
+ resampler = av.AudioResampler(
209
+ format='fltp',
210
+ layout='stereo',
211
+ rate=target_sr,
212
+ )
213
+
214
+ for frame in container.decode(stream):
215
+ for resampled_frame in resampler.resample(frame):
216
+ # to_ndarray() on fltp gives shape (channels, samples)
217
+ arr = resampled_frame.to_ndarray()
218
+ clip_frames.append(torch.from_numpy(arr))
219
+
220
+ # Flush the resampler to get any remaining samples
221
+ for resampled_frame in resampler.resample(None):
222
+ arr = resampled_frame.to_ndarray()
223
+ clip_frames.append(torch.from_numpy(arr))
224
+
225
+ if not clip_frames:
226
+ continue
227
+
228
+ # Concatenate all frame blocks along the samples dimension (dim 1)
229
+ waveform = torch.cat(clip_frames, dim=1) # Shape: [2, total_clip_samples]
230
+
231
+ # Calculate interactive trim boundaries
232
+ trim_start_frames = float(seg.get("trimStart", 0))
233
+ length_frames = float(seg.get("length", 1))
234
+ start_frames = float(seg.get("start", 0))
235
+
236
+ start_sample_src = int(trim_start_frames / frame_rate * target_sr)
237
+ length_samples = int(length_frames / frame_rate * target_sr)
238
+ end_sample_src = start_sample_src + length_samples
239
+
240
+ if start_sample_src < 0: start_sample_src = 0
241
+ if end_sample_src > waveform.shape[1]:
242
+ end_sample_src = waveform.shape[1]
243
+
244
+ actual_length = end_sample_src - start_sample_src
245
+ if actual_length <= 0: continue
246
+
247
+ # Extract the correct segment of the audio
248
+ clip_waveform = waveform[:, start_sample_src:end_sample_src]
249
+
250
+ # Position onto the timeline
251
+ start_sample_dst = int(start_frames / frame_rate * target_sr)
252
+
253
+ if start_sample_dst >= out_waveform.shape[1]:
254
+ continue
255
+
256
+ end_sample_dst = start_sample_dst + actual_length
257
+
258
+ # Clip any trailing overflow so we don't index past the timeline bounds
259
+ if end_sample_dst > out_waveform.shape[1]:
260
+ actual_length = out_waveform.shape[1] - start_sample_dst
261
+ clip_waveform = clip_waveform[:, :actual_length]
262
+ end_sample_dst = start_sample_dst + actual_length
263
+
264
+ if actual_length <= 0:
265
+ continue
266
+
267
+ # Additive composite (allows clips overlapping to sum together naturally)
268
+ out_waveform[:, start_sample_dst:end_sample_dst] += clip_waveform
269
+
270
+ except Exception as e:
271
+ log.warning("[PromptRelay] Audio process error for segment %s: %s", seg.get("fileName"), e)
272
+ continue
273
+
274
+ return {"waveform": out_waveform.unsqueeze(0), "sample_rate": target_sr}
275
+
276
+
277
+ def _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames):
278
+ """Convert pixel-space segment lengths to integer latent-space lengths using the
279
+ largest-remainder method. Targets the full `latent_frames` when the pixel sum looks
280
+ like full coverage (within one stride of latent_frames * stride). Otherwise targets
281
+ round(total_pixel / temporal_stride) so partial-coverage timelines stay partial.
282
+ """
283
+ if not pixel_lengths:
284
+ return []
285
+ total_pixel = sum(pixel_lengths)
286
+ if total_pixel <= 0:
287
+ return [1] * len(pixel_lengths)
288
+
289
+ naive_total = max(1, round(total_pixel / temporal_stride))
290
+ target_total = min(latent_frames, naive_total)
291
+ # Within one frame of full → user clearly intended full coverage; pin to latent_frames.
292
+ if target_total >= latent_frames - 1:
293
+ target_total = latent_frames
294
+
295
+ exact = [p * target_total / total_pixel for p in pixel_lengths]
296
+ result = [int(e) for e in exact]
297
+ diff = target_total - sum(result)
298
+ if diff > 0:
299
+ order = sorted(range(len(exact)), key=lambda i: -(exact[i] - int(exact[i])))
300
+ for k in range(diff):
301
+ result[order[k % len(order)]] += 1
302
+
303
+ # Ensure every segment has ≥ 1 latent frame (steal from the largest if needed).
304
+ for i in range(len(result)):
305
+ if result[i] < 1:
306
+ max_idx = max(range(len(result)), key=lambda j: result[j])
307
+ if result[max_idx] > 1:
308
+ result[max_idx] -= 1
309
+ result[i] = 1
310
+
311
+ return result
312
+
313
+
314
+ def _encode_relay(model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon):
315
+ for name, val in (("global_prompt", global_prompt),
316
+ ("local_prompts", local_prompts),
317
+ ("segment_lengths", segment_lengths)):
318
+ if val is None:
319
+ raise ValueError(
320
+ f"PromptRelay: '{name}' arrived as None. "
321
+ "Likely causes: a stale workflow JSON saved with null, the timeline "
322
+ "editor's web extension failing to load, or an upstream node returning None. "
323
+ "Set the field to an empty string or fix the upstream connection."
324
+ )
325
+
326
+ # Split prompts but do NOT filter out empty ones yet, so we can detect them
327
+ locals_list = [p.strip() for p in local_prompts.split("|")]
328
+
329
+ # Check if any specific segment is empty
330
+ for p in locals_list:
331
+ if not p:
332
+ raise ValueError("There is a segment on the timeline missing a prompt!")
333
+
334
+ if not locals_list or (len(locals_list) == 1 and not locals_list[0]):
335
+ raise ValueError("At least one local prompt is required.")
336
+
337
+ arch, patch_size, temporal_stride = detect_model_type(model)
338
+
339
+ samples = latent["samples"]
340
+ latent_frames = samples.shape[2]
341
+ tokens_per_frame = (samples.shape[3] // patch_size[1]) * (samples.shape[4] // patch_size[2])
342
+
343
+ parsed_lengths = None
344
+ if segment_lengths.strip():
345
+ pixel_lengths = [int(float(x.strip())) for x in segment_lengths.split(",") if x.strip()]
346
+ parsed_lengths = _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames)
347
+
348
+ raw_tokenizer = get_raw_tokenizer(clip)
349
+ full_prompt, token_ranges = map_token_indices(raw_tokenizer, global_prompt, locals_list)
350
+
351
+ log.info("[PromptRelay] Global: tokens [0:%d] (%d tokens)", token_ranges[0][0], token_ranges[0][0])
352
+ for i, (s, e) in enumerate(token_ranges):
353
+ log.info("[PromptRelay] Segment %d: tokens [%d:%d] (%d tokens)", i, s, e, e - s)
354
+
355
+ conditioning = clip.encode_from_tokens_scheduled(clip.tokenize(full_prompt))
356
+
357
+ effective_lengths = distribute_segment_lengths(len(locals_list), latent_frames, parsed_lengths)
358
+
359
+ log.info(
360
+ "[PromptRelay] Latent: %d frames, %d tokens/frame, segments: %s",
361
+ latent_frames, tokens_per_frame, effective_lengths,
362
+ )
363
+
364
+ q_token_idx = build_segments(token_ranges, effective_lengths, epsilon, None)
365
+ mask_fn = create_mask_fn(q_token_idx, tokens_per_frame, latent_frames)
366
+
367
+ patched = model.clone()
368
+ apply_patches(patched, arch, mask_fn)
369
+
370
+ return patched, conditioning
371
+
372
+
373
+ class LTXDirector(io.ComfyNode):
374
+ """WYSIWYG timeline variant — segments and lengths come from a visual editor in the node UI."""
375
+
376
+ @classmethod
377
+ def define_schema(cls):
378
+ return io.Schema(
379
+ node_id="LTXDirector",
380
+ display_name="LTX Director",
381
+ category="WhatDreamsCost",
382
+ description=(
383
+ "Same as Prompt Relay Encode, but local prompts and segment lengths are edited "
384
+ "visually as draggable blocks on a timeline. The duration_frames input only sets the "
385
+ "timeline scale (pixel space) — actual frame count is still read from the latent."
386
+ ),
387
+ inputs=[
388
+ io.Model.Input("model"),
389
+ io.Clip.Input("clip"),
390
+ io.Vae.Input("audio_vae", optional=True, tooltip="Optional. Connect an Audio VAE to generate audio latents."),
391
+ io.Latent.Input("optional_latent", optional=True, tooltip="Optional. Connect a latent to override the auto-generated one."),
392
+ io.String.Input(
393
+ "global_prompt", multiline=True, default="",
394
+ tooltip="Conditions the entire video. Anchors persistent characters, objects, and scene context.",
395
+ ),
396
+ io.Int.Input(
397
+ "duration_frames", default=120, min=1, max=10000, step=1,
398
+ tooltip="Total timeline length in pixel-space frames. Used by the editor for visual scale only.",
399
+ ),
400
+ io.Float.Input(
401
+ "duration_seconds", default=5, min=0.1, max=1000.0, step=0.01,
402
+ tooltip="Total timeline duration in seconds (computed/synced from frames).",
403
+ ),
404
+ io.String.Input(
405
+ "timeline_data", default="",
406
+ tooltip="JSON state of the timeline editor (auto-managed; do not edit by hand).",
407
+ ),
408
+ io.Boolean.Input(
409
+ "use_custom_audio", default=False, optional=True,
410
+ tooltip="Toggle between using timeline audio (ON) and generating audio from scratch (OFF).",
411
+ ),
412
+ io.String.Input(
413
+ "local_prompts", multiline=True, default="",
414
+ tooltip="Auto-populated from the timeline editor.",
415
+ ),
416
+ io.String.Input(
417
+ "segment_lengths", default="",
418
+ tooltip="Auto-populated from the timeline editor (pixel-space frame counts).",
419
+ ),
420
+ io.Float.Input(
421
+ "epsilon", default=0.001, min=0.0001, max=0.99, step=0.0001,
422
+ tooltip="Penalty decay parameter. Values below ~0.1 all produce sharp boundaries (paper default 0.001). For softer transitions, try 0.5 or higher.",
423
+ ),
424
+ io.Float.Input(
425
+ "frame_rate", default=24, min=1, max=240, step=1, optional=True,
426
+ tooltip="Frames per second — only affects how time is displayed in the timeline editor when time_units is set to 'seconds'.",
427
+ ),
428
+ io.Combo.Input(
429
+ "display_mode", options=["frames", "seconds"], default="seconds", optional=True,
430
+ tooltip="Display the ruler, segment ranges, length input, and total in frames or seconds. Internal storage is always pixel-space frames.",
431
+ ),
432
+ io.String.Input(
433
+ "guide_strength", default="",
434
+ tooltip="Auto-populated from the timeline editor (comma-separated guide strengths for image segments).",
435
+ ),
436
+ io.Int.Input(
437
+ "custom_width", default=0, min=0, max=8192, step=1, optional=True,
438
+ tooltip="Target output width for all image segments. Set to 0 to use the original image width.",
439
+ ),
440
+ io.Int.Input(
441
+ "custom_height", default=0, min=0, max=8192, step=1, optional=True,
442
+ tooltip="Target output height for all image segments. Set to 0 to use the original image height.",
443
+ ),
444
+ io.Combo.Input(
445
+ "resize_method",
446
+ options=["maintain aspect ratio", "stretch to fit", "pad", "crop"],
447
+ default="maintain aspect ratio",
448
+ optional=True,
449
+ tooltip="How to resize image segments to fit the target dimensions.",
450
+ ),
451
+ io.Int.Input(
452
+ "divisible_by", default=32, min=1, max=256, step=1, optional=True,
453
+ tooltip="Snap the final output image dimensions to be divisible by this number (e.g. 32 for LTX).",
454
+ ),
455
+ io.Int.Input(
456
+ "img_compression", default=18, min=0, max=100, step=1, optional=True,
457
+ tooltip="H.264 CRF compression to apply to each guide image. 0 = no compression, higher = more artefacts.",
458
+ ),
459
+ ],
460
+ outputs=[
461
+ io.Model.Output(display_name="model"),
462
+ io.Conditioning.Output(display_name="positive"),
463
+ io.Latent.Output(display_name="video_latent", tooltip="Auto-generated LTXV empty latent (only populated when no latent is connected)."),
464
+ io.Latent.Output(display_name="audio_latent", tooltip="Auto-generated audio latent (uses custom audio if enabled)."),
465
+ GuideData.Output(display_name="guide_data"),
466
+ io.Float.Output(display_name="frame_rate", tooltip="The frame rate used for the timeline."),
467
+ io.Audio.Output(display_name="combined_audio", tooltip="Combined timeline audio layout."),
468
+ ],
469
+ )
470
+
471
+ @classmethod
472
+ def execute(cls, model, clip, global_prompt, duration_frames, duration_seconds,
473
+ timeline_data, local_prompts, segment_lengths, guide_strength="", epsilon=1e-3,
474
+ frame_rate=24, display_mode="seconds",
475
+ custom_width=768, custom_height=512, resize_method="maintain aspect ratio",
476
+ divisible_by=32, img_compression=0, audio_vae=None, optional_latent=None,
477
+ use_custom_audio=False) -> io.NodeOutput:
478
+
479
+ # --- Build guide_data from image segments FIRST (to derive output dimensions) ---
480
+ guide_data = {"images": [], "insert_frames": [], "strengths": [], "frame_rate": frame_rate}
481
+ derived_w, derived_h = custom_width, custom_height
482
+ try:
483
+ tdata = json.loads(timeline_data) if timeline_data else {}
484
+ img_segs = [
485
+ s for s in tdata.get("segments", [])
486
+ if s.get("type", "image") == "image"
487
+ and (s.get("imageFile") or s.get("imageB64"))
488
+ and int(s.get("start", 0)) < duration_frames # exclude segments fully outside duration
489
+ ]
490
+ img_segs.sort(key=lambda s: s["start"])
491
+
492
+ strengths = []
493
+ if guide_strength.strip():
494
+ strengths = [float(x.strip()) for x in guide_strength.split(",") if x.strip()]
495
+
496
+ for idx, seg in enumerate(img_segs):
497
+ tensor = _load_image_tensor(seg)
498
+
499
+ # Apply resize
500
+ src_h, src_w = tensor.shape[1], tensor.shape[2]
501
+
502
+ def snap(val, div):
503
+ return max(div, (val // div) * div)
504
+
505
+ if custom_width > 0 and custom_height > 0:
506
+ # Both dimensions set — apply selected resize_method (pad, crop, stretch, maintain AR)
507
+ tensor = _resize_image(tensor, custom_width, custom_height, resize_method, divisible_by)
508
+ elif custom_width > 0:
509
+ # Width only — scale height from AR, snap both, then resize to exact dimensions
510
+ tgt_w = snap(custom_width, divisible_by)
511
+ tgt_h = snap(int(src_h * tgt_w / src_w), divisible_by)
512
+ tensor = _resize_image(tensor, tgt_w, tgt_h, "stretch to fit", divisible_by)
513
+ elif custom_height > 0:
514
+ # Height only — scale width from AR, snap both, then resize to exact dimensions
515
+ tgt_h = snap(custom_height, divisible_by)
516
+ tgt_w = snap(int(src_w * tgt_h / src_h), divisible_by)
517
+ tensor = _resize_image(tensor, tgt_w, tgt_h, "stretch to fit", divisible_by)
518
+ else:
519
+ # Both zero — keep original dimensions, just snap to divisible_by
520
+ tensor = _resize_image(tensor, src_w, src_h, "maintain aspect ratio", divisible_by)
521
+
522
+
523
+ # Apply compression
524
+ if img_compression > 0:
525
+ tensor = _compress_image(tensor, img_compression)
526
+
527
+ # Record dimensions of the first processed image for latent generation
528
+ if idx == 0:
529
+ derived_h = tensor.shape[1]
530
+ derived_w = tensor.shape[2]
531
+
532
+ strength = strengths[idx] if idx < len(strengths) else 1.0
533
+ guide_data["images"].append(tensor)
534
+ guide_data["insert_frames"].append(int(seg["start"]))
535
+ guide_data["strengths"].append(float(strength))
536
+
537
+ # If no images were loaded from the timeline, create a dummy image at strength 0
538
+ # to prevent artifacts in text-to-video mode.
539
+ if not guide_data["images"]:
540
+ w = derived_w if derived_w > 0 else 768
541
+ h = derived_h if derived_h > 0 else 512
542
+ w = (w // 32) * 32
543
+ h = (h // 32) * 32
544
+
545
+ dummy_image = torch.zeros((1, h, w, 3), dtype=torch.float32)
546
+ guide_data["images"].append(dummy_image)
547
+ guide_data["insert_frames"].append(0)
548
+ guide_data["strengths"].append(0.0)
549
+
550
+ derived_w = w
551
+ derived_h = h
552
+ except Exception as e:
553
+ log.warning("[PromptRelay] Could not build guide_data: %s", e)
554
+
555
+ # --- Auto-generate LTXV latent if none was provided ---
556
+ ltxv_length = duration_frames + 1
557
+ if optional_latent is None:
558
+ latent_w = max(32, (derived_w // 32) * 32)
559
+ latent_h = max(32, (derived_h // 32) * 32)
560
+ # LTXV temporal: ((length - 1) // 8) + 1 latent frames; invert to get pixel frames -> length
561
+ latent_t = ((ltxv_length - 1) // 8) + 1
562
+ samples = torch.zeros(
563
+ [1, 128, latent_t, latent_h // 32, latent_w // 32],
564
+ device=comfy.model_management.intermediate_device(),
565
+ )
566
+ latent = {"samples": samples}
567
+ log.info(
568
+ "[PromptRelay] Auto-generated LTXV latent: %dx%d, %d pixel frames (%d latent frames)",
569
+ latent_w, latent_h, ltxv_length, latent_t,
570
+ )
571
+ else:
572
+ latent = optional_latent
573
+
574
+ patched, conditioning = _encode_relay(
575
+ model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon,
576
+ )
577
+
578
+ # --- Build Audio Output ---
579
+ audio_out = _build_combined_audio(timeline_data, ltxv_length, float(frame_rate))
580
+
581
+ # --- Audio Latent Generation ---
582
+ audio_latent = {}
583
+
584
+ if audio_vae is not None:
585
+ # Helper to generate empty latent
586
+ def get_empty_latent():
587
+ # Support both raw AudioVAE objects and ComfyUI VAE wrappers.
588
+ inner = getattr(audio_vae, "first_stage_model", audio_vae)
589
+ z_channels = audio_vae.latent_channels
590
+ audio_freq = inner.latent_frequency_bins
591
+ num_audio_latents = inner.num_of_latents_from_frames(ltxv_length, float(frame_rate))
592
+ audio_latents = torch.zeros(
593
+ (1, z_channels, num_audio_latents, audio_freq),
594
+ device=comfy.model_management.intermediate_device(),
595
+ )
596
+ return {"samples": audio_latents, "type": "audio"}
597
+
598
+ if use_custom_audio:
599
+ try:
600
+ if audio_out is not None:
601
+ # 1. Encode audio waveform into latent space
602
+ waveform = audio_out["waveform"]
603
+ if waveform.ndim == 2:
604
+ waveform = waveform.unsqueeze(0)
605
+ if waveform.ndim != 3:
606
+ raise ValueError(
607
+ f"Expected custom audio waveform with 2 or 3 dims, got shape {tuple(waveform.shape)}"
608
+ )
609
+
610
+ # Wrapped ComfyUI VAE expects (batch, samples, channels);
611
+ # raw AudioVAE expects a dict with waveform in (batch, channels, samples).
612
+ if hasattr(audio_vae, "first_stage_model"):
613
+ latent_samples = audio_vae.encode(waveform.movedim(1, -1))
614
+ else:
615
+ latent_samples = audio_vae.encode({
616
+ "waveform": waveform,
617
+ "sample_rate": audio_out["sample_rate"],
618
+ })
619
+
620
+ if latent_samples.numel() == 0:
621
+ raise ValueError("Encoded audio latent is empty (0 elements).")
622
+
623
+ # 2. Create solid mask with value 0.0 (0 means keep/use conditioning, 1 means generate noise)
624
+ mask = torch.full(
625
+ (1, latent_samples.shape[-2], latent_samples.shape[-1]),
626
+ 0.0,
627
+ dtype=torch.float32,
628
+ device=comfy.model_management.intermediate_device()
629
+ )
630
+
631
+ # 3. Set Latent Noise Mask
632
+ audio_latent = {
633
+ "samples": latent_samples,
634
+ "type": "audio",
635
+ "noise_mask": mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1]))
636
+ }
637
+ log.info("[PromptRelay] Generated custom audio latent with noise mask (value=0.0).")
638
+ else:
639
+ raise ValueError("No audio waveform to encode.")
640
+ except Exception as e:
641
+ log.error("[PromptRelay] Failed to generate custom audio latent: %s", e)
642
+ raise e
643
+ else:
644
+ # Generate empty latent
645
+ try:
646
+ audio_latent = get_empty_latent()
647
+ log.info("[PromptRelay] Auto-generated empty audio latent.")
648
+ except Exception as e:
649
+ log.error("[PromptRelay] Could not generate empty audio latent: %s", e)
650
+ raise e
651
+
652
+ return io.NodeOutput(patched, conditioning, latent, audio_latent, guide_data, float(frame_rate), audio_out)
653
+
654
+
655
+ NODE_CLASS_MAPPINGS = {
656
+ "LTXDirector": LTXDirector,
657
+ }
658
+
659
+ NODE_DISPLAY_NAME_MAPPINGS = {
660
+ "PromptRelayEncodeTimeline": "Prompt Relay Encode (Timeline)",
661
+ }