perf: reduce redundant media work in director
This commit is contained in:
646
ltx_director.py
646
ltx_director.py
@@ -11,13 +11,14 @@ import torch.nn.functional as F
|
|||||||
import av
|
import av
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
import folder_paths
|
from urllib.parse import parse_qs, urlparse
|
||||||
import comfy.model_management
|
import folder_paths
|
||||||
from server import PromptServer
|
import comfy.model_management
|
||||||
from aiohttp import web
|
from server import PromptServer
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
from comfy_api.latest import io
|
from comfy_api.latest import io
|
||||||
|
|
||||||
@@ -68,11 +69,145 @@ MotionGuideData = io.Custom("MOTION_GUIDE_DATA")
|
|||||||
|
|
||||||
# --- Licon MSR engine fixed parameters (were node widgets; hardcoded for a clean UI) ---
|
# --- Licon MSR engine fixed parameters (were node widgets; hardcoded for a clean UI) ---
|
||||||
# Slideshow length. 41 is the Licon training default (valid: 17 / 25 / 33 / 41).
|
# Slideshow length. 41 is the Licon training default (valid: 17 / 25 / 33 / 41).
|
||||||
MSR_PREFIX_FRAMES = 41
|
MSR_PREFIX_FRAMES = 41
|
||||||
# latent_downscale_factor for the IC-LoRA reference frames. Tied to how the MSR LoRA was
|
# latent_downscale_factor for the IC-LoRA reference frames. Tied to how the MSR LoRA was
|
||||||
# trained; Licon MSR V1 uses full-resolution references (1.0). This is INDEPENDENT of any
|
# trained; Licon MSR V1 uses full-resolution references (1.0). This is INDEPENDENT of any
|
||||||
# multi-stage scale_by / x2 upscaling, which LTXDirectorGuide handles downstream.
|
# multi-stage scale_by / x2 upscaling, which LTXDirectorGuide handles downstream.
|
||||||
MSR_LATENT_DOWNSCALE = 1.0
|
MSR_LATENT_DOWNSCALE = 1.0
|
||||||
|
EMPTY_IMAGE_SHAPE = (1, 512, 512, 3)
|
||||||
|
PEAK_BUCKETS = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_image_tensor() -> torch.Tensor:
|
||||||
|
return torch.zeros(EMPTY_IMAGE_SHAPE, dtype=torch.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_b64_payload(payload: str) -> str:
|
||||||
|
if payload and "," in payload:
|
||||||
|
return payload.split(",", 1)[1]
|
||||||
|
return payload or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _image_to_tensor(img: Image.Image) -> torch.Tensor:
|
||||||
|
arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
|
||||||
|
return torch.from_numpy(arr).unsqueeze(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_input_path(path: str, input_dir: str = None) -> str | None:
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
input_dir = input_dir or folder_paths.get_input_directory()
|
||||||
|
clean_path = path.replace("\\", "/")
|
||||||
|
candidates = [
|
||||||
|
os.path.join(input_dir, clean_path),
|
||||||
|
os.path.join(input_dir, "whatdreamscost", os.path.basename(clean_path)),
|
||||||
|
os.path.join(input_dir, os.path.basename(clean_path)),
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_image_reference(b64_or_url: str = "", filename: str = None, cache: dict | None = None,
|
||||||
|
input_dir: str = None) -> torch.Tensor:
|
||||||
|
"""Load a reference image from a base64 string, a Comfy /view URL, or an input filename."""
|
||||||
|
if not b64_or_url and not filename:
|
||||||
|
return _empty_image_tensor()
|
||||||
|
|
||||||
|
input_dir = input_dir or folder_paths.get_input_directory()
|
||||||
|
cache = cache if cache is not None else {}
|
||||||
|
|
||||||
|
file_path = None
|
||||||
|
if b64_or_url and "view?" in b64_or_url:
|
||||||
|
try:
|
||||||
|
parsed = urlparse(b64_or_url)
|
||||||
|
q = parse_qs(parsed.query)
|
||||||
|
fname = q.get("filename", [None])[0]
|
||||||
|
subfolder = q.get("subfolder", [""])[0]
|
||||||
|
if fname:
|
||||||
|
file_path = _resolve_input_path(os.path.join(subfolder, fname), input_dir)
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f"[PromptRelay] URL parsing failed for {b64_or_url}: {e}")
|
||||||
|
|
||||||
|
if file_path is None and filename:
|
||||||
|
file_path = _resolve_input_path(filename, input_dir)
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
cache_key = ("file", file_path)
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
tensor = _image_to_tensor(Image.open(file_path))
|
||||||
|
cache[cache_key] = tensor
|
||||||
|
return tensor
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f"[PromptRelay] File image loading failed for {file_path}: {e}")
|
||||||
|
|
||||||
|
if b64_or_url:
|
||||||
|
try:
|
||||||
|
b64_str = _normalise_b64_payload(b64_or_url)
|
||||||
|
cache_key = ("b64", b64_str)
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
img_bytes = base64.b64decode(b64_str)
|
||||||
|
tensor = _image_to_tensor(Image.open(_io.BytesIO(img_bytes)))
|
||||||
|
cache[cache_key] = tensor
|
||||||
|
return tensor
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f"[PromptRelay] Base64 decoding failed: {e}")
|
||||||
|
|
||||||
|
return _empty_image_tensor()
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_signal_peaks(samples: np.ndarray, num_peaks: int = PEAK_BUCKETS) -> list[float]:
|
||||||
|
if samples.size == 0:
|
||||||
|
return [0.0] * num_peaks
|
||||||
|
|
||||||
|
step = max(1, int(math.ceil(samples.size / num_peaks)))
|
||||||
|
padded = np.pad(samples, (0, step * num_peaks - samples.size), mode="constant")
|
||||||
|
chunks = padded.reshape(num_peaks, step)
|
||||||
|
return (np.max(np.abs(chunks), axis=1) / 32767.0).astype(np.float32).tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_audio_bytes(container, stream, resampler) -> bytes:
|
||||||
|
audio_bytes = bytearray()
|
||||||
|
for frame in container.decode(stream):
|
||||||
|
for resampled_frame in resampler.resample(frame):
|
||||||
|
audio_bytes.extend(resampled_frame.to_ndarray().tobytes())
|
||||||
|
for resampled_frame in resampler.resample(None):
|
||||||
|
audio_bytes.extend(resampled_frame.to_ndarray().tobytes())
|
||||||
|
return bytes(audio_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_resize_dims(src_w: int, src_h: int, custom_width: int, custom_height: int, divisible_by: int) -> tuple[int, int, str]:
|
||||||
|
def snap(val, div):
|
||||||
|
return max(div, (val // div) * div)
|
||||||
|
|
||||||
|
if custom_width > 0 and custom_height > 0:
|
||||||
|
return custom_width, custom_height, None
|
||||||
|
if custom_width > 0:
|
||||||
|
tgt_w = snap(custom_width, divisible_by)
|
||||||
|
tgt_h = snap(int(src_h * tgt_w / src_w), divisible_by)
|
||||||
|
return tgt_w, tgt_h, "stretch to fit"
|
||||||
|
if custom_height > 0:
|
||||||
|
tgt_h = snap(custom_height, divisible_by)
|
||||||
|
tgt_w = snap(int(src_w * tgt_h / src_h), divisible_by)
|
||||||
|
return tgt_w, tgt_h, "stretch to fit"
|
||||||
|
return src_w, src_h, "maintain aspect ratio"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_video_dimensions(file_path: str) -> tuple[int | None, int | None]:
|
||||||
|
try:
|
||||||
|
with av.open(file_path) as container:
|
||||||
|
stream = container.streams.video[0]
|
||||||
|
return stream.width or stream.codec_context.width, stream.height or stream.codec_context.height
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def _preprocess_prompts_with_characters(global_prompt, local_prompts, char1="", char2="", char3=""):
|
def _preprocess_prompts_with_characters(global_prompt, local_prompts, char1="", char2="", char3=""):
|
||||||
@@ -111,47 +246,9 @@ def _preprocess_prompts_with_characters(global_prompt, local_prompts, char1="",
|
|||||||
return gp, " | ".join(processed_locals)
|
return gp, " | ".join(processed_locals)
|
||||||
|
|
||||||
|
|
||||||
def _load_image_source(b64_or_url: str, filename: str = None) -> torch.Tensor:
|
def _load_image_source(b64_or_url: str, filename: str = None, cache: dict | None = None,
|
||||||
"""Load a reference image from a base64 string, a Comfy /view URL, or an input filename."""
|
input_dir: str = None) -> torch.Tensor:
|
||||||
if not b64_or_url and not filename:
|
return _load_image_reference(b64_or_url=b64_or_url, filename=filename, cache=cache, input_dir=input_dir)
|
||||||
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
|
||||||
|
|
||||||
if b64_or_url and "view?" in b64_or_url:
|
|
||||||
try:
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
parsed = urlparse(b64_or_url)
|
|
||||||
q = parse_qs(parsed.query)
|
|
||||||
fname = q.get("filename", [None])[0]
|
|
||||||
subfolder = q.get("subfolder", [""])[0]
|
|
||||||
if fname:
|
|
||||||
file_path = os.path.join(folder_paths.get_input_directory(), subfolder, fname)
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
img = Image.open(file_path).convert("RGB")
|
|
||||||
arr = np.array(img, dtype=np.float32) / 255.0
|
|
||||||
return torch.from_numpy(arr).unsqueeze(0)
|
|
||||||
except Exception as e:
|
|
||||||
log.debug(f"[PromptRelay] URL parsing failed for {b64_or_url}: {e}")
|
|
||||||
|
|
||||||
if filename:
|
|
||||||
file_path = os.path.join(folder_paths.get_input_directory(), filename)
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
img = Image.open(file_path).convert("RGB")
|
|
||||||
arr = np.array(img, dtype=np.float32) / 255.0
|
|
||||||
return torch.from_numpy(arr).unsqueeze(0)
|
|
||||||
|
|
||||||
if b64_or_url:
|
|
||||||
try:
|
|
||||||
b64_str = b64_or_url
|
|
||||||
if "," in b64_str:
|
|
||||||
b64_str = b64_str.split(",", 1)[1]
|
|
||||||
img_bytes = base64.b64decode(b64_str)
|
|
||||||
img = Image.open(_io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
arr = np.array(img, dtype=np.float32) / 255.0
|
|
||||||
return torch.from_numpy(arr).unsqueeze(0)
|
|
||||||
except Exception as e:
|
|
||||||
log.debug(f"[PromptRelay] Base64 decoding failed: {e}")
|
|
||||||
|
|
||||||
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
|
||||||
|
|
||||||
|
|
||||||
def _execute_comfy_node(node_class, **kwargs):
|
def _execute_comfy_node(node_class, **kwargs):
|
||||||
@@ -392,26 +489,15 @@ async def unload_ollama_endpoint(request):
|
|||||||
return web.json_response({"status": "error", "message": str(e)})
|
return web.json_response({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
def read_wav_peaks(wav_path):
|
def read_wav_peaks(wav_path):
|
||||||
import wave
|
import wave
|
||||||
peaks = []
|
with wave.open(wav_path, 'rb') as w:
|
||||||
with wave.open(wav_path, 'rb') as w:
|
n_frames = w.getnframes()
|
||||||
n_frames = w.getnframes()
|
if n_frames <= 0:
|
||||||
if n_frames > 0:
|
return [0.0] * PEAK_BUCKETS
|
||||||
frames_bytes = w.readframes(n_frames)
|
frames_bytes = w.readframes(n_frames)
|
||||||
samples = np.frombuffer(frames_bytes, dtype=np.int16)
|
samples = np.frombuffer(frames_bytes, dtype=np.int16)
|
||||||
num_peaks = 200
|
return _compute_signal_peaks(samples)
|
||||||
step = max(1, len(samples) // num_peaks)
|
|
||||||
for i in range(num_peaks):
|
|
||||||
chunk = samples[i * step : (i + 1) * step]
|
|
||||||
if len(chunk) > 0:
|
|
||||||
max_val = np.max(np.abs(chunk)) / 32767.0
|
|
||||||
peaks.append(float(max_val))
|
|
||||||
else:
|
|
||||||
peaks.append(0.0)
|
|
||||||
else:
|
|
||||||
peaks = [0.0] * 200
|
|
||||||
return peaks
|
|
||||||
|
|
||||||
|
|
||||||
def extract_audio_from_video(video_path):
|
def extract_audio_from_video(video_path):
|
||||||
@@ -445,20 +531,9 @@ def extract_audio_from_video(video_path):
|
|||||||
rate=44100,
|
rate=44100,
|
||||||
)
|
)
|
||||||
|
|
||||||
audio_bytes = bytearray()
|
audio_bytes = _read_audio_bytes(container, stream, resampler)
|
||||||
|
if not audio_bytes:
|
||||||
for frame in container.decode(stream):
|
return None, None
|
||||||
for resampled_frame in resampler.resample(frame):
|
|
||||||
arr = resampled_frame.to_ndarray()
|
|
||||||
audio_bytes.extend(arr.tobytes())
|
|
||||||
|
|
||||||
# Flush resampler
|
|
||||||
for resampled_frame in resampler.resample(None):
|
|
||||||
arr = resampled_frame.to_ndarray()
|
|
||||||
audio_bytes.extend(arr.tobytes())
|
|
||||||
|
|
||||||
if not audio_bytes:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
# Write WAV file
|
# Write WAV file
|
||||||
with wave.open(output_wav, 'wb') as w:
|
with wave.open(output_wav, 'wb') as w:
|
||||||
@@ -468,17 +543,8 @@ def extract_audio_from_video(video_path):
|
|||||||
w.writeframes(audio_bytes)
|
w.writeframes(audio_bytes)
|
||||||
|
|
||||||
# Calculate peaks
|
# Calculate peaks
|
||||||
peaks = []
|
samples = np.frombuffer(audio_bytes, dtype=np.int16)
|
||||||
samples = np.frombuffer(audio_bytes, dtype=np.int16)
|
peaks = _compute_signal_peaks(samples)
|
||||||
num_peaks = 200
|
|
||||||
step = max(1, len(samples) // num_peaks)
|
|
||||||
for i in range(num_peaks):
|
|
||||||
chunk = samples[i * step : (i + 1) * step]
|
|
||||||
if len(chunk) > 0:
|
|
||||||
max_val = np.max(np.abs(chunk)) / 32767.0
|
|
||||||
peaks.append(float(max_val))
|
|
||||||
else:
|
|
||||||
peaks.append(0.0)
|
|
||||||
|
|
||||||
input_dir = folder_paths.get_input_directory()
|
input_dir = folder_paths.get_input_directory()
|
||||||
rel_output = os.path.relpath(output_wav, input_dir).replace('\\', '/')
|
rel_output = os.path.relpath(output_wav, input_dir).replace('\\', '/')
|
||||||
@@ -509,30 +575,11 @@ def get_audio_peaks(audio_path):
|
|||||||
layout='mono',
|
layout='mono',
|
||||||
rate=8000,
|
rate=8000,
|
||||||
)
|
)
|
||||||
audio_bytes = bytearray()
|
audio_bytes = _read_audio_bytes(container, stream, resampler)
|
||||||
for frame in container.decode(stream):
|
if not audio_bytes:
|
||||||
for resampled_frame in resampler.resample(frame):
|
return None
|
||||||
arr = resampled_frame.to_ndarray()
|
samples = np.frombuffer(audio_bytes, dtype=np.int16)
|
||||||
audio_bytes.extend(arr.tobytes())
|
return _compute_signal_peaks(samples)
|
||||||
for resampled_frame in resampler.resample(None):
|
|
||||||
arr = resampled_frame.to_ndarray()
|
|
||||||
audio_bytes.extend(arr.tobytes())
|
|
||||||
|
|
||||||
if not audio_bytes:
|
|
||||||
return None
|
|
||||||
|
|
||||||
peaks = []
|
|
||||||
samples = np.frombuffer(audio_bytes, dtype=np.int16)
|
|
||||||
num_peaks = 200
|
|
||||||
step = max(1, len(samples) // num_peaks)
|
|
||||||
for i in range(num_peaks):
|
|
||||||
chunk = samples[i * step : (i + 1) * step]
|
|
||||||
if len(chunk) > 0:
|
|
||||||
max_val = np.max(np.abs(chunk)) / 32767.0
|
|
||||||
peaks.append(float(max_val))
|
|
||||||
else:
|
|
||||||
peaks.append(0.0)
|
|
||||||
return peaks
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[LTXDirector] Failed to get audio peaks via PyAV: {e}")
|
print(f"[LTXDirector] Failed to get audio peaks via PyAV: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -657,38 +704,24 @@ async def ltx_director_upload_chunk(request):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _load_image_tensor(seg: dict) -> torch.Tensor:
|
def _load_image_tensor(seg: dict, cache: dict | None = None, input_dir: str = None) -> torch.Tensor:
|
||||||
"""Decode an image from the ComfyUI input folder (if imageFile provided) or fallback to base64
|
"""Decode an image from the ComfyUI input folder (if imageFile provided) or fallback to base64
|
||||||
to a ComfyUI-style image tensor of shape [1, H, W, 3], float32 in [0, 1]."""
|
to a ComfyUI-style image tensor of shape [1, H, W, 3], float32 in [0, 1]."""
|
||||||
if seg.get("imageFile"):
|
return _load_image_reference(
|
||||||
file_path = os.path.join(folder_paths.get_input_directory(), seg["imageFile"])
|
b64_or_url=seg.get("imageB64", ""),
|
||||||
if os.path.exists(file_path):
|
filename=seg.get("imageFile"),
|
||||||
img = Image.open(file_path).convert("RGB")
|
cache=cache,
|
||||||
arr = np.array(img, dtype=np.float32) / 255.0
|
input_dir=input_dir,
|
||||||
return torch.from_numpy(arr).unsqueeze(0)
|
)
|
||||||
|
|
||||||
b64_str = seg.get("imageB64", "")
|
|
||||||
if not b64_str or b64_str.startswith("/view?"):
|
def _load_video_tensor(seg: dict, frame_rate: float, input_dir: str = None) -> torch.Tensor:
|
||||||
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
"""Extracts a sequence of frames from a video file based on the segment's trim parameters,
|
||||||
|
and returns them as an [N, H, W, 3] float32 tensor."""
|
||||||
if "," in b64_str:
|
file_path = _resolve_input_path(seg.get("imageFile", ""), input_dir)
|
||||||
b64_str = b64_str.split(",", 1)[1]
|
|
||||||
|
if not file_path:
|
||||||
try:
|
return _empty_image_tensor()
|
||||||
img_bytes = base64.b64decode(b64_str)
|
|
||||||
img = Image.open(_io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
arr = np.array(img, dtype=np.float32) / 255.0
|
|
||||||
return torch.from_numpy(arr).unsqueeze(0)
|
|
||||||
except:
|
|
||||||
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
|
||||||
|
|
||||||
def _load_video_tensor(seg: dict, frame_rate: float) -> torch.Tensor:
|
|
||||||
"""Extracts a sequence of frames from a video file based on the segment's trim parameters,
|
|
||||||
and returns them as an [N, H, W, 3] float32 tensor."""
|
|
||||||
file_path = os.path.join(folder_paths.get_input_directory(), seg.get("imageFile", ""))
|
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
|
||||||
|
|
||||||
trim_start_frames = float(seg.get("trimStart", 0))
|
trim_start_frames = float(seg.get("trimStart", 0))
|
||||||
length_frames = float(seg.get("length", 1))
|
length_frames = float(seg.get("length", 1))
|
||||||
@@ -723,11 +756,11 @@ def _load_video_tensor(seg: dict, frame_rate: float) -> torch.Tensor:
|
|||||||
|
|
||||||
if len(frames) >= int(length_frames):
|
if len(frames) >= int(length_frames):
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"[PromptRelay] Video extract error: {e}")
|
log.warning(f"[PromptRelay] Video extract error: {e}")
|
||||||
|
|
||||||
if not frames:
|
if not frames:
|
||||||
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
return _empty_image_tensor()
|
||||||
|
|
||||||
frames_np = np.array(frames, dtype=np.float32) / 255.0
|
frames_np = np.array(frames, dtype=np.float32) / 255.0
|
||||||
return torch.from_numpy(frames_np)
|
return torch.from_numpy(frames_np)
|
||||||
@@ -848,21 +881,24 @@ def _compress_image(tensor: torch.Tensor, crf: int) -> torch.Tensor:
|
|||||||
return tensor
|
return tensor
|
||||||
|
|
||||||
|
|
||||||
def _build_combined_audio(timeline_data_str: str, start_frame: int, duration_frames: int, frame_rate: float, override_audio: bool = False) -> dict:
|
def _build_combined_audio(timeline_data_value, start_frame: int, duration_frames: int, frame_rate: float, override_audio: bool = False) -> dict:
|
||||||
"""Parses timeline JSON, loads/trims audio directly from memory using PyAV,
|
"""Parses timeline JSON, loads/trims audio directly from memory using PyAV,
|
||||||
and aligns to a global timeline yielding ComfyUI's format.
|
and aligns to a global timeline yielding ComfyUI's format.
|
||||||
Output length explicitly mimics the timeline's duration_frames length."""
|
Output length explicitly mimics the timeline's duration_frames length."""
|
||||||
target_sr = 44100
|
target_sr = 44100
|
||||||
total_samples = max(1, int(math.ceil(duration_frames / frame_rate * target_sr)))
|
total_samples = max(1, int(math.ceil(duration_frames / frame_rate * target_sr)))
|
||||||
empty_audio = {"waveform": torch.zeros((1, 2, total_samples), dtype=torch.float32), "sample_rate": target_sr}
|
empty_audio = {"waveform": torch.zeros((1, 2, total_samples), dtype=torch.float32), "sample_rate": target_sr}
|
||||||
|
|
||||||
if not timeline_data_str:
|
if not timeline_data_value:
|
||||||
return empty_audio
|
return empty_audio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(timeline_data_str)
|
if isinstance(timeline_data_value, str):
|
||||||
is_retake = data.get("retakeMode", False)
|
data = json.loads(timeline_data_value)
|
||||||
if is_retake and data.get("retakeVideo"):
|
else:
|
||||||
|
data = timeline_data_value
|
||||||
|
is_retake = data.get("retakeMode", False)
|
||||||
|
if is_retake and data.get("retakeVideo"):
|
||||||
retake_vid = data.get("retakeVideo")
|
retake_vid = data.get("retakeVideo")
|
||||||
audio_segs = [{
|
audio_segs = [{
|
||||||
"videoFile": retake_vid.get("imageFile") or retake_vid.get("fileName"),
|
"videoFile": retake_vid.get("imageFile") or retake_vid.get("fileName"),
|
||||||
@@ -888,17 +924,9 @@ def _build_combined_audio(timeline_data_str: str, start_frame: int, duration_fra
|
|||||||
buffer = None
|
buffer = None
|
||||||
file_key = "videoFile" if override_audio else "audioFile"
|
file_key = "videoFile" if override_audio else "audioFile"
|
||||||
if seg.get(file_key):
|
if seg.get(file_key):
|
||||||
file_path = os.path.join(folder_paths.get_input_directory(), seg[file_key])
|
file_path = _resolve_input_path(seg[file_key])
|
||||||
if not os.path.exists(file_path):
|
if file_path:
|
||||||
# Try fallback under whatdreamscost subfolder
|
buffer = file_path
|
||||||
basename = os.path.basename(seg[file_key])
|
|
||||||
fallback_path = os.path.join(folder_paths.get_input_directory(), "whatdreamscost", basename)
|
|
||||||
if os.path.exists(fallback_path):
|
|
||||||
file_path = fallback_path
|
|
||||||
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
with open(file_path, "rb") as f:
|
|
||||||
buffer = _io.BytesIO(f.read())
|
|
||||||
|
|
||||||
if not override_audio and not buffer and seg.get("audioB64"):
|
if not override_audio and not buffer and seg.get("audioB64"):
|
||||||
b64 = seg.get("audioB64")
|
b64 = seg.get("audioB64")
|
||||||
@@ -917,7 +945,7 @@ def _build_combined_audio(timeline_data_str: str, start_frame: int, duration_fra
|
|||||||
clip_frames = []
|
clip_frames = []
|
||||||
|
|
||||||
# Use PyAV to decode directly from memory buffer
|
# Use PyAV to decode directly from memory buffer
|
||||||
with av.open(buffer) as container:
|
with av.open(buffer) as container:
|
||||||
if not container.streams.audio:
|
if not container.streams.audio:
|
||||||
continue
|
continue
|
||||||
stream = container.streams.audio[0]
|
stream = container.streams.audio[0]
|
||||||
@@ -1258,19 +1286,21 @@ class LTXDirector(io.ComfyNode):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, model, clip, start_second, end_second, duration_seconds, start_frame, end_frame, duration_frames,
|
def execute(cls, model, clip, start_second, end_second, duration_seconds, start_frame, end_frame, duration_frames,
|
||||||
timeline_data, local_prompts, segment_lengths, global_prompt="", guide_strength="", epsilon=1e-3,
|
timeline_data, local_prompts, segment_lengths, global_prompt="", guide_strength="", epsilon=1e-3,
|
||||||
frame_rate=24, display_mode="seconds",
|
frame_rate=24, display_mode="seconds",
|
||||||
custom_width=768, custom_height=512, resize_method="maintain aspect ratio",
|
custom_width=768, custom_height=512, resize_method="maintain aspect ratio",
|
||||||
divisible_by=32, img_compression=0, audio_vae=None, optional_latent=None,
|
divisible_by=32, img_compression=0, audio_vae=None, optional_latent=None,
|
||||||
use_custom_audio=False, inpaint_audio=True, use_custom_motion=True, override_audio=False,
|
use_custom_audio=False, inpaint_audio=True, use_custom_motion=True, override_audio=False,
|
||||||
vae=None, reference_strength=1.0, ref_images=None,
|
vae=None, reference_strength=1.0, ref_images=None,
|
||||||
start=None, end=None, duration=None) -> io.NodeOutput:
|
start=None, end=None, duration=None) -> io.NodeOutput:
|
||||||
|
input_dir = folder_paths.get_input_directory()
|
||||||
# Parse timeline data
|
image_cache = {}
|
||||||
try:
|
|
||||||
tdata = json.loads(timeline_data) if timeline_data else {}
|
# Parse timeline data
|
||||||
except Exception as e:
|
try:
|
||||||
|
tdata = json.loads(timeline_data) if timeline_data else {}
|
||||||
|
except Exception as e:
|
||||||
log.error(f"[LTXDirector] execute timeline_data parse error: {e}")
|
log.error(f"[LTXDirector] execute timeline_data parse error: {e}")
|
||||||
tdata = {}
|
tdata = {}
|
||||||
|
|
||||||
@@ -1327,14 +1357,14 @@ class LTXDirector(io.ComfyNode):
|
|||||||
if legacy_b64 and not images_list:
|
if legacy_b64 and not images_list:
|
||||||
images_list = [{"b64": legacy_b64, "name": char_info.get("fileName", "")}]
|
images_list = [{"b64": legacy_b64, "name": char_info.get("fileName", "")}]
|
||||||
|
|
||||||
slot_tensors = []
|
slot_tensors = []
|
||||||
for img_info in images_list:
|
for img_info in images_list:
|
||||||
image_b64 = img_info.get("b64", "")
|
image_b64 = img_info.get("b64", "")
|
||||||
file_name = img_info.get("name", "")
|
file_name = img_info.get("name", "")
|
||||||
tensor = _load_image_source(image_b64, file_name)
|
tensor = _load_image_source(image_b64, file_name, cache=image_cache, input_dir=input_dir)
|
||||||
char_images.append(tensor)
|
char_images.append(tensor)
|
||||||
slot_tensors.append(tensor)
|
slot_tensors.append(tensor)
|
||||||
char_slot_images.append(slot_tensors)
|
char_slot_images.append(slot_tensors)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("[LTXDirector] Could not process character slot inputs: %s", e)
|
log.warning("[LTXDirector] Could not process character slot inputs: %s", e)
|
||||||
|
|
||||||
@@ -1367,40 +1397,33 @@ class LTXDirector(io.ComfyNode):
|
|||||||
if guide_strength.strip():
|
if guide_strength.strip():
|
||||||
strengths = [float(x.strip()) for x in guide_strength.split(",") if x.strip()]
|
strengths = [float(x.strip()) for x in guide_strength.split(",") if x.strip()]
|
||||||
|
|
||||||
for idx, seg in enumerate(img_segs):
|
for idx, seg in enumerate(img_segs):
|
||||||
seg_start = int(seg.get("start", 0))
|
seg_start = int(seg.get("start", 0))
|
||||||
offset = max(0, start_frame - seg_start)
|
offset = max(0, start_frame - seg_start)
|
||||||
|
seg_length = int(seg.get("length", 1))
|
||||||
if seg.get("type") == "video":
|
seg_for_load = seg
|
||||||
if offset > 0:
|
|
||||||
seg["trimStart"] = float(seg.get("trimStart", 0)) + offset
|
if seg.get("type") == "video":
|
||||||
seg["length"] = max(1, int(seg.get("length", 1)) - offset)
|
if offset > 0:
|
||||||
tensor = _load_video_tensor(seg, float(frame_rate))
|
seg_for_load = dict(seg)
|
||||||
else:
|
seg_for_load["trimStart"] = float(seg.get("trimStart", 0)) + offset
|
||||||
tensor = _load_image_tensor(seg)
|
seg_for_load["length"] = max(1, seg_length - offset)
|
||||||
|
tensor = _load_video_tensor(seg_for_load, float(frame_rate), input_dir=input_dir)
|
||||||
# Apply resize
|
else:
|
||||||
src_h, src_w = tensor.shape[1], tensor.shape[2]
|
tensor = _load_image_tensor(seg, cache=image_cache, input_dir=input_dir)
|
||||||
|
|
||||||
def snap(val, div):
|
# Apply resize
|
||||||
return max(div, (val // div) * div)
|
src_h, src_w = tensor.shape[1], tensor.shape[2]
|
||||||
|
tgt_w, tgt_h, resize_override = _target_resize_dims(
|
||||||
if custom_width > 0 and custom_height > 0:
|
src_w, src_h, custom_width, custom_height, divisible_by
|
||||||
# Both dimensions set — apply selected resize_method (pad, crop, stretch, maintain AR)
|
)
|
||||||
tensor = _resize_image(tensor, custom_width, custom_height, resize_method, divisible_by)
|
tensor = _resize_image(
|
||||||
elif custom_width > 0:
|
tensor,
|
||||||
# Width only — scale height from AR, snap both, then resize to exact dimensions
|
tgt_w,
|
||||||
tgt_w = snap(custom_width, divisible_by)
|
tgt_h,
|
||||||
tgt_h = snap(int(src_h * tgt_w / src_w), divisible_by)
|
resize_override or resize_method,
|
||||||
tensor = _resize_image(tensor, tgt_w, tgt_h, "stretch to fit", divisible_by)
|
divisible_by,
|
||||||
elif custom_height > 0:
|
)
|
||||||
# Height only — scale width from AR, snap both, then resize to exact dimensions
|
|
||||||
tgt_h = snap(custom_height, divisible_by)
|
|
||||||
tgt_w = snap(int(src_w * tgt_h / src_h), divisible_by)
|
|
||||||
tensor = _resize_image(tensor, tgt_w, tgt_h, "stretch to fit", divisible_by)
|
|
||||||
else:
|
|
||||||
# Both zero — keep original dimensions, just snap to divisible_by
|
|
||||||
tensor = _resize_image(tensor, src_w, src_h, "maintain aspect ratio", divisible_by)
|
|
||||||
|
|
||||||
|
|
||||||
# Apply compression
|
# Apply compression
|
||||||
@@ -1408,15 +1431,15 @@ class LTXDirector(io.ComfyNode):
|
|||||||
tensor = _compress_image(tensor, img_compression)
|
tensor = _compress_image(tensor, img_compression)
|
||||||
|
|
||||||
# Record dimensions of the first processed image for latent generation
|
# Record dimensions of the first processed image for latent generation
|
||||||
if idx == 0:
|
if idx == 0:
|
||||||
derived_h = tensor.shape[1]
|
derived_h = tensor.shape[1]
|
||||||
derived_w = tensor.shape[2]
|
derived_w = tensor.shape[2]
|
||||||
|
|
||||||
if seg.get("isEndFrame"):
|
if seg.get("isEndFrame"):
|
||||||
insert_frame = max(0, seg_start + int(seg.get("length", 1)) - 1 - start_frame)
|
insert_frame = max(0, seg_start + seg_length - 1 - start_frame)
|
||||||
else:
|
else:
|
||||||
insert_frame = max(0, seg_start - start_frame)
|
insert_frame = max(0, seg_start - start_frame)
|
||||||
strength = strengths[idx] if idx < len(strengths) else 1.0
|
strength = strengths[idx] if idx < len(strengths) else 1.0
|
||||||
guide_data["images"].append(tensor)
|
guide_data["images"].append(tensor)
|
||||||
guide_data["insert_frames"].append(insert_frame)
|
guide_data["insert_frames"].append(insert_frame)
|
||||||
guide_data["strengths"].append(float(strength))
|
guide_data["strengths"].append(float(strength))
|
||||||
@@ -1424,75 +1447,49 @@ class LTXDirector(io.ComfyNode):
|
|||||||
# If no images were loaded from the timeline, create a dummy image at strength 0
|
# If no images were loaded from the timeline, create a dummy image at strength 0
|
||||||
# to prevent artifacts in text-to-video mode.
|
# to prevent artifacts in text-to-video mode.
|
||||||
if not guide_data["images"] and optional_latent is None:
|
if not guide_data["images"] and optional_latent is None:
|
||||||
src_w = derived_w if derived_w > 0 else 768
|
src_w = derived_w if derived_w > 0 else 768
|
||||||
src_h = derived_h if derived_h > 0 else 512
|
src_h = derived_h if derived_h > 0 else 512
|
||||||
|
|
||||||
# If there's an IC-LoRA video or retake base video on the timeline, extract its dimensions for accurate aspect ratio scaling
|
# If there's an IC-LoRA video or retake base video on the timeline, extract its dimensions for accurate aspect ratio scaling
|
||||||
tdata_motion = json.loads(timeline_data) if timeline_data else {}
|
found_dims = False
|
||||||
found_dims = False
|
|
||||||
|
# Check for retake base video first
|
||||||
# Check for retake base video first
|
is_retake = tdata.get("retakeMode", False)
|
||||||
is_retake = tdata_motion.get("retakeMode", False)
|
retake_vid = tdata.get("retakeVideo") or {}
|
||||||
retake_vid = tdata_motion.get("retakeVideo") or {}
|
retake_file = retake_vid.get("imageFile", "") if isinstance(retake_vid, dict) else ""
|
||||||
retake_file = retake_vid.get("imageFile", "") if isinstance(retake_vid, dict) else ""
|
if is_retake and retake_file:
|
||||||
if is_retake and retake_file:
|
r_path = _resolve_input_path(retake_file, input_dir)
|
||||||
r_path = os.path.join(folder_paths.get_input_directory(), retake_file)
|
if r_path:
|
||||||
if not os.path.exists(r_path):
|
src_dims = _extract_video_dimensions(r_path)
|
||||||
basename = os.path.basename(retake_file)
|
if all(src_dims):
|
||||||
fallback_path = os.path.join(folder_paths.get_input_directory(), "whatdreamscost", basename)
|
src_w, src_h = src_dims
|
||||||
if os.path.exists(fallback_path):
|
found_dims = True
|
||||||
r_path = fallback_path
|
|
||||||
if os.path.exists(r_path):
|
# Fallback to normal motion segments
|
||||||
try:
|
if not found_dims:
|
||||||
with av.open(r_path) as container:
|
for mseg in tdata.get("motionSegments", []):
|
||||||
stream = container.streams.video[0]
|
v_file = mseg.get("videoFile")
|
||||||
src_w = stream.width or stream.codec_context.width
|
if v_file:
|
||||||
src_h = stream.height or stream.codec_context.height
|
v_path = _resolve_input_path(v_file, input_dir)
|
||||||
found_dims = True
|
if v_path:
|
||||||
except:
|
src_dims = _extract_video_dimensions(v_path)
|
||||||
pass
|
if all(src_dims):
|
||||||
|
src_w, src_h = src_dims
|
||||||
# Fallback to normal motion segments
|
found_dims = True
|
||||||
if not found_dims:
|
break
|
||||||
for mseg in tdata_motion.get("motionSegments", []):
|
|
||||||
v_file = mseg.get("videoFile")
|
# Create a dummy tensor of the exact source dimensions
|
||||||
if v_file:
|
tensor = torch.zeros((1, src_h, src_w, 3), dtype=torch.float32)
|
||||||
v_path = os.path.join(folder_paths.get_input_directory(), v_file)
|
tgt_w, tgt_h, resize_override = _target_resize_dims(
|
||||||
if not os.path.exists(v_path):
|
src_w, src_h, custom_width, custom_height, divisible_by
|
||||||
basename = os.path.basename(v_file)
|
)
|
||||||
fallback_path = os.path.join(folder_paths.get_input_directory(), "whatdreamscost", basename)
|
tensor = _resize_image(
|
||||||
if os.path.exists(fallback_path):
|
tensor,
|
||||||
v_path = fallback_path
|
tgt_w,
|
||||||
if os.path.exists(v_path):
|
tgt_h,
|
||||||
try:
|
resize_override or resize_method,
|
||||||
with av.open(v_path) as container:
|
divisible_by,
|
||||||
stream = container.streams.video[0]
|
)
|
||||||
src_w = stream.width or stream.codec_context.width
|
|
||||||
src_h = stream.height or stream.codec_context.height
|
|
||||||
found_dims = True
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Create a dummy tensor of the exact source dimensions
|
|
||||||
tensor = torch.zeros((1, src_h, src_w, 3), dtype=torch.float32)
|
|
||||||
|
|
||||||
def snap(val, div):
|
|
||||||
return max(div, (val // div) * div)
|
|
||||||
|
|
||||||
# Route the dummy tensor through the exact same resizing pipeline
|
|
||||||
if custom_width > 0 and custom_height > 0:
|
|
||||||
tensor = _resize_image(tensor, custom_width, custom_height, resize_method, divisible_by)
|
|
||||||
elif custom_width > 0:
|
|
||||||
tgt_w = snap(custom_width, divisible_by)
|
|
||||||
tgt_h = snap(int(src_h * tgt_w / src_w), divisible_by)
|
|
||||||
tensor = _resize_image(tensor, tgt_w, tgt_h, "stretch to fit", divisible_by)
|
|
||||||
elif custom_height > 0:
|
|
||||||
tgt_h = snap(custom_height, divisible_by)
|
|
||||||
tgt_w = snap(int(src_w * tgt_h / src_h), divisible_by)
|
|
||||||
tensor = _resize_image(tensor, tgt_w, tgt_h, "stretch to fit", divisible_by)
|
|
||||||
else:
|
|
||||||
tensor = _resize_image(tensor, src_w, src_h, "maintain aspect ratio", divisible_by)
|
|
||||||
|
|
||||||
guide_data["images"].append(tensor)
|
guide_data["images"].append(tensor)
|
||||||
guide_data["insert_frames"].append(0)
|
guide_data["insert_frames"].append(0)
|
||||||
@@ -1703,7 +1700,7 @@ class LTXDirector(io.ComfyNode):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Build Audio Output ---
|
# --- Build Audio Output ---
|
||||||
audio_out = _build_combined_audio(timeline_data, start_frame, ltxv_length, float(frame_rate), override_audio=override_audio)
|
audio_out = _build_combined_audio(tdata, start_frame, ltxv_length, float(frame_rate), override_audio=override_audio)
|
||||||
|
|
||||||
# --- Audio Latent Generation ---
|
# --- Audio Latent Generation ---
|
||||||
audio_latent = {}
|
audio_latent = {}
|
||||||
@@ -1831,13 +1828,12 @@ class LTXDirector(io.ComfyNode):
|
|||||||
log.error("[PromptRelay] Could not generate empty audio latent: %s", e)
|
log.error("[PromptRelay] Could not generate empty audio latent: %s", e)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
# --- Motion guide output from timeline video segments ---
|
# --- Motion guide output from timeline video segments ---
|
||||||
motion_guide_data = {"segments": [], "frame_rate": float(frame_rate), "duration_frames": int(duration_frames), "resize_method": resize_method}
|
motion_guide_data = {"segments": [], "frame_rate": float(frame_rate), "duration_frames": int(duration_frames), "resize_method": resize_method}
|
||||||
try:
|
try:
|
||||||
tdata = json.loads(timeline_data) if timeline_data else {}
|
if use_custom_motion:
|
||||||
if use_custom_motion:
|
motion_segments = tdata.get("motionSegments", [])
|
||||||
motion_segments = tdata.get("motionSegments", [])
|
else:
|
||||||
else:
|
|
||||||
motion_segments = []
|
motion_segments = []
|
||||||
for seg in motion_segments:
|
for seg in motion_segments:
|
||||||
seg_start = int(seg.get("start", 0))
|
seg_start = int(seg.get("start", 0))
|
||||||
@@ -1878,4 +1874,4 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"PromptRelayEncodeTimeline": "Prompt Relay Encode (Timeline)",
|
"PromptRelayEncodeTimeline": "Prompt Relay Encode (Timeline)",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user