From 5dc011db1f58dcd7eb0b9ae5883eda33b61994fe Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Sat, 25 Apr 2026 19:57:37 -0700 Subject: [PATCH] fix: ltx node calls dispatch correctly for V1 (KJ/core) vs V3 (LTXV*) api --- ltx_video_generation.py | 276 ++++++++++++++++++++-------------------- 1 file changed, 141 insertions(+), 135 deletions(-) diff --git a/ltx_video_generation.py b/ltx_video_generation.py index 867f31f..a87323d 100644 --- a/ltx_video_generation.py +++ b/ltx_video_generation.py @@ -133,6 +133,53 @@ def get_ltx_node_classes(): return found +def _call_node(node_cls, **kwargs): + """Call a Comfy node regardless of V1/V3 API. Returns whatever the node returns + (typically a tuple, but V3 nodes return an object with .output). + + V3 (io.ComfyNode subclass): class-level .execute() classmethod — must be inside + a CurrentNodeContext block. + V1 (legacy): node has a FUNCTION attribute naming an instance method; instantiate + the class then call that method on the instance. KJ nodes and most core ComfyUI + nodes work this way. + """ + # Try V3 first via subclass check (avoids triggering AttributeError noise on V1 nodes + # that may also happen to have an unrelated `execute` attribute somewhere). + try: + from comfy_api.latest import io + if isinstance(node_cls, type) and issubclass(node_cls, io.ComfyNode): + return node_cls.execute(**kwargs) + except (ImportError, TypeError): + pass + + # V1 path: FUNCTION attribute names the instance method to invoke + fn_name = getattr(node_cls, "FUNCTION", None) + if fn_name: + inst = node_cls() + return getattr(inst, fn_name)(**kwargs) + + # Last-resort fallback: try class-level .execute() + if hasattr(node_cls, "execute"): + return node_cls.execute(**kwargs) + + raise RuntimeError("Cannot determine entry method for node " + repr(node_cls)) + + +def _unwrap(result, idx=0): + """Normalize a node's return value to a single positional output by index. + + V3 nodes return objects with .output (a tuple). V1 nodes return tuples directly. + Some return a single object; some return a list. Handle all of these. + """ + if hasattr(result, "output"): + out = result.output + else: + out = result + if isinstance(out, (tuple, list)): + return out[idx] + return out + + def preflight_ltx(config): """Validate that LTX gen can run for this config. Raises RuntimeError on any missing node, missing model file, or missing ffmpeg. @@ -220,12 +267,13 @@ def load_ltx_models(config): out = {} - # --- Diffusion model (V3 API: DiffusionModelLoaderKJ.execute) --- + # --- Diffusion model (V1 API: DiffusionModelLoaderKJ.patch_and_load) --- if diff_key in model_cache.ltx_diffusion_model_cache: out["diffusion_model"] = model_cache.ltx_diffusion_model_cache[diff_key] else: with CurrentNodeContext(prompt_id=dummy_prompt_id, node_id="uscg_ltx_diff", list_index=0): - r = nodes_map["DiffusionModelLoaderKJ"].execute( + r = _call_node( + nodes_map["DiffusionModelLoaderKJ"], model_name=config["model"], weight_dtype=weight_dtype, compute_dtype=compute_dtype, @@ -233,7 +281,7 @@ def load_ltx_models(config): sage_attention="disabled", enable_fp16_accumulation=False, ) - m = r.output[0] if hasattr(r, "output") else r[0] + m = _unwrap(r, 0) model_cache.ltx_diffusion_model_cache[diff_key] = m model_cache._evict_to_max(model_cache.ltx_diffusion_model_cache, 1) out["diffusion_model"] = m @@ -242,63 +290,46 @@ def load_ltx_models(config): if clip_key in model_cache.ltx_dual_clip_cache: out["dual_clip"] = model_cache.ltx_dual_clip_cache[clip_key] else: - clip_loader_cls = nodes_map["DualCLIPLoader"] - clip_loader = clip_loader_cls() - # Verify load_clip is the right method name; if not, find it by introspection. - if hasattr(clip_loader, "load_clip"): - r = clip_loader.load_clip( - clip_name1=config["clip_models"][0], - clip_name2=config["clip_models"][1], - type="ltxv", - device="default", - ) - else: - # Fallback: find the first non-dunder callable method that's not from object - method_name = next( - (n for n in dir(clip_loader) - if not n.startswith("_") and callable(getattr(clip_loader, n)) - and n not in ("INPUT_TYPES",)), - None - ) - if method_name is None: - raise RuntimeError("Could not find DualCLIPLoader entry method") - r = getattr(clip_loader, method_name)( - clip_name1=config["clip_models"][0], - clip_name2=config["clip_models"][1], - type="ltxv", - device="default", - ) - c = r[0] if isinstance(r, tuple) else r + r = _call_node( + nodes_map["DualCLIPLoader"], + clip_name1=config["clip_models"][0], + clip_name2=config["clip_models"][1], + type="ltxv", + device="default", + ) + c = _unwrap(r, 0) model_cache.ltx_dual_clip_cache[clip_key] = c model_cache._evict_to_max(model_cache.ltx_dual_clip_cache, 2) out["dual_clip"] = c - # --- Video VAE (V3 API: VAELoaderKJ.execute) --- + # --- Video VAE (V1 API: VAELoaderKJ.load_vae) --- if vvae_key in model_cache.ltx_video_vae_cache: out["video_vae"] = model_cache.ltx_video_vae_cache[vvae_key] else: with CurrentNodeContext(prompt_id=dummy_prompt_id, node_id="uscg_ltx_vvae", list_index=0): - r = nodes_map["VAELoaderKJ"].execute( + r = _call_node( + nodes_map["VAELoaderKJ"], vae_name=config["vae_video"], device=device, weight_dtype="bf16", ) - v = r.output[0] if hasattr(r, "output") else r[0] + v = _unwrap(r, 0) model_cache.ltx_video_vae_cache[vvae_key] = v model_cache._evict_to_max(model_cache.ltx_video_vae_cache, 1) out["video_vae"] = v - # --- Audio VAE (V3 API: VAELoaderKJ.execute) --- + # --- Audio VAE (V1 API: VAELoaderKJ.load_vae) --- if avae_key in model_cache.ltx_audio_vae_cache: out["audio_vae"] = model_cache.ltx_audio_vae_cache[avae_key] else: with CurrentNodeContext(prompt_id=dummy_prompt_id, node_id="uscg_ltx_avae", list_index=0): - r = nodes_map["VAELoaderKJ"].execute( + r = _call_node( + nodes_map["VAELoaderKJ"], vae_name=config["vae_audio"], device=device, weight_dtype="bf16", ) - v = r.output[0] if hasattr(r, "output") else r[0] + v = _unwrap(r, 0) model_cache.ltx_audio_vae_cache[avae_key] = v model_cache._evict_to_max(model_cache.ltx_audio_vae_cache, 1) out["audio_vae"] = v @@ -307,21 +338,8 @@ def load_ltx_models(config): if upsc_key in model_cache.ltx_latent_upscaler_cache: out["latent_upscaler"] = model_cache.ltx_latent_upscaler_cache[upsc_key] else: - ld_cls = nodes_map["LatentUpscaleModelLoader"] - ld = ld_cls() - if hasattr(ld, "load_model"): - r = ld.load_model(model_name=config["latent_upscaler"]) - else: - method_name = next( - (n for n in dir(ld) - if not n.startswith("_") and callable(getattr(ld, n)) - and n not in ("INPUT_TYPES",)), - None - ) - if method_name is None: - raise RuntimeError("Could not find LatentUpscaleModelLoader entry method") - r = getattr(ld, method_name)(model_name=config["latent_upscaler"]) - u = r[0] if isinstance(r, tuple) else r + r = _call_node(nodes_map["LatentUpscaleModelLoader"], model_name=config["latent_upscaler"]) + u = _unwrap(r, 0) model_cache.ltx_latent_upscaler_cache[upsc_key] = u model_cache._evict_to_max(model_cache.ltx_latent_upscaler_cache, 1) out["latent_upscaler"] = u @@ -343,25 +361,16 @@ def encode_ltx_prompts(dual_clip, positive_text, negative_text, frame_rate): if encoder is None: import nodes encoder = nodes.NODE_CLASS_MAPPINGS["CLIPTextEncode"] - enc = encoder() - pos = enc.encode(clip=dual_clip, text=positive_text)[0] - neg = enc.encode(clip=dual_clip, text=negative_text)[0] + pos = _unwrap(_call_node(encoder, clip=dual_clip, text=positive_text), 0) + neg = _unwrap(_call_node(encoder, clip=dual_clip, text=negative_text), 0) - # LTXVConditioning wraps with frame_rate - cond_node = nodes_map["LTXVConditioning"]() - if hasattr(cond_node, "execute"): - # V3 API - from comfy_execution.utils import CurrentNodeContext - with CurrentNodeContext(prompt_id=str(uuid.uuid4()), node_id="uscg_ltx_cond", list_index=0): - r = cond_node.execute(positive=pos, negative=neg, frame_rate=int(frame_rate)) - out = r.output if hasattr(r, "output") else r - else: - # V1 API - find the actual method (may be `apply` or similar) - method_name = next(n for n in dir(cond_node) if not n.startswith("_") and callable(getattr(cond_node, n))) - out = getattr(cond_node, method_name)(positive=pos, negative=neg, frame_rate=int(frame_rate)) + # LTXVConditioning wraps with frame_rate (V3 API: ComfyNode subclass) + from comfy_execution.utils import CurrentNodeContext + with CurrentNodeContext(prompt_id=str(uuid.uuid4()), node_id="uscg_ltx_cond", list_index=0): + r = _call_node(nodes_map["LTXVConditioning"], positive=pos, negative=neg, frame_rate=int(frame_rate)) - return out[0], out[1] + return _unwrap(r, 0), _unwrap(r, 1) def ltx_video_generate(config, ltx_models, output_path): @@ -424,118 +433,114 @@ def ltx_video_generate(config, ltx_models, output_path): # Run all node executions inside a single mock V3 context with CurrentNodeContext(prompt_id=prompt_id, node_id="uscg_ltx_gen", list_index=0): # 1. Empty video latent (dimensions /2 for latent space) - empty_video = nodes_map["EmptyLTXVLatentVideo"].execute( - width=width // 2, height=height // 2, length=frames, batch_size=1 + empty_video_latent = _unwrap( + _call_node(nodes_map["EmptyLTXVLatentVideo"], + width=width // 2, height=height // 2, length=frames, batch_size=1), + 0, ) - empty_video_latent = empty_video.output[0] if hasattr(empty_video, "output") else empty_video[0] # 2. Empty audio latent - empty_audio = nodes_map["LTXVEmptyLatentAudio"].execute( - frames_number=frames, frame_rate=frame_rate, batch_size=1, audio_vae=audio_vae + empty_audio_latent = _unwrap( + _call_node(nodes_map["LTXVEmptyLatentAudio"], + frames_number=frames, frame_rate=frame_rate, batch_size=1, audio_vae=audio_vae), + 0, ) - empty_audio_latent = empty_audio.output[0] if hasattr(empty_audio, "output") else empty_audio[0] # 3. Concat AV (stage 1 input). For t2v-only Phase A, no img inplace. - stage1_input = nodes_map["LTXVConcatAVLatent"].execute( - video_latent=empty_video_latent, audio_latent=empty_audio_latent + stage1_input_latent = _unwrap( + _call_node(nodes_map["LTXVConcatAVLatent"], + video_latent=empty_video_latent, audio_latent=empty_audio_latent), + 0, ) - stage1_input_latent = stage1_input.output[0] if hasattr(stage1_input, "output") else stage1_input[0] # 4. Stage 1 sampling - sampler1 = nodes_map["KSamplerSelect"].execute(sampler_name=sampler_stage1) - sampler1_obj = sampler1.output[0] if hasattr(sampler1, "output") else sampler1[0] + sampler1_obj = _unwrap(_call_node(nodes_map["KSamplerSelect"], sampler_name=sampler_stage1), 0) - sigmas1_node = nodes_map["ManualSigmas"].execute(sigmas=sigmas1) - sigmas1_tensor = sigmas1_node.output[0] if hasattr(sigmas1_node, "output") else sigmas1_node[0] + sigmas1_tensor = _unwrap(_call_node(nodes_map["ManualSigmas"], sigmas=sigmas1), 0) - noise1 = nodes_map["RandomNoise"].execute(noise_seed=seed_stage1) - noise1_obj = noise1.output[0] if hasattr(noise1, "output") else noise1[0] + noise1_obj = _unwrap(_call_node(nodes_map["RandomNoise"], noise_seed=seed_stage1), 0) - guider1 = nodes_map["CFGGuider"].execute( - model=diff_model, positive=cond_pos, negative=cond_neg, cfg=cfg_scale + guider1_obj = _unwrap( + _call_node(nodes_map["CFGGuider"], + model=diff_model, positive=cond_pos, negative=cond_neg, cfg=cfg_scale), + 0, ) - guider1_obj = guider1.output[0] if hasattr(guider1, "output") else guider1[0] - sampled1 = nodes_map["SamplerCustomAdvanced"].execute( - noise=noise1_obj, guider=guider1_obj, sampler=sampler1_obj, - sigmas=sigmas1_tensor, latent_image=stage1_input_latent, + sampled1_latent = _unwrap( + _call_node(nodes_map["SamplerCustomAdvanced"], + noise=noise1_obj, guider=guider1_obj, sampler=sampler1_obj, + sigmas=sigmas1_tensor, latent_image=stage1_input_latent), + 0, ) - sampled1_latent = sampled1.output[0] if hasattr(sampled1, "output") else sampled1[0] # 5. Separate AV - sep1 = nodes_map["LTXVSeparateAVLatent"].execute(av_latent=sampled1_latent) - if hasattr(sep1, "output"): - video1, audio1 = sep1.output[0], sep1.output[1] - else: - video1, audio1 = sep1[0], sep1[1] + sep1 = _call_node(nodes_map["LTXVSeparateAVLatent"], av_latent=sampled1_latent) + video1, audio1 = _unwrap(sep1, 0), _unwrap(sep1, 1) # 6. Spatial upscale of video latent - ups = nodes_map["LTXVLatentUpsampler"].execute( - samples=video1, upscale_model=upscaler, vae=video_vae + upscaled_video = _unwrap( + _call_node(nodes_map["LTXVLatentUpsampler"], + samples=video1, upscale_model=upscaler, vae=video_vae), + 0, ) - upscaled_video = ups.output[0] if hasattr(ups, "output") else ups[0] # 7. Phase A skips LTXVImgToVideoInplace stage 2 (t2v + no upscale-input # refinement image). The upscaled latent goes straight to crop guides. # 8. Crop guides on the upscaled latent - crop = nodes_map["LTXVCropGuides"].execute( - positive=cond_pos, negative=cond_neg, latent=upscaled_video - ) - if hasattr(crop, "output"): - crop_pos, crop_neg = crop.output[0], crop.output[1] - else: - crop_pos, crop_neg = crop[0], crop[1] + crop = _call_node(nodes_map["LTXVCropGuides"], + positive=cond_pos, negative=cond_neg, latent=upscaled_video) + crop_pos, crop_neg = _unwrap(crop, 0), _unwrap(crop, 1) # 9. Reconcat AV for stage 2 - stage2_input = nodes_map["LTXVConcatAVLatent"].execute( - video_latent=upscaled_video, audio_latent=audio1 + stage2_input_latent = _unwrap( + _call_node(nodes_map["LTXVConcatAVLatent"], + video_latent=upscaled_video, audio_latent=audio1), + 0, ) - stage2_input_latent = stage2_input.output[0] if hasattr(stage2_input, "output") else stage2_input[0] # 10. Stage 2 sampling - sampler2 = nodes_map["KSamplerSelect"].execute(sampler_name=sampler_stage2) - sampler2_obj = sampler2.output[0] if hasattr(sampler2, "output") else sampler2[0] + sampler2_obj = _unwrap(_call_node(nodes_map["KSamplerSelect"], sampler_name=sampler_stage2), 0) - sigmas2_node = nodes_map["ManualSigmas"].execute(sigmas=sigmas2) - sigmas2_tensor = sigmas2_node.output[0] if hasattr(sigmas2_node, "output") else sigmas2_node[0] + sigmas2_tensor = _unwrap(_call_node(nodes_map["ManualSigmas"], sigmas=sigmas2), 0) - noise2 = nodes_map["RandomNoise"].execute(noise_seed=seed_stage2) - noise2_obj = noise2.output[0] if hasattr(noise2, "output") else noise2[0] + noise2_obj = _unwrap(_call_node(nodes_map["RandomNoise"], noise_seed=seed_stage2), 0) - guider2 = nodes_map["CFGGuider"].execute( - model=diff_model, positive=crop_pos, negative=crop_neg, cfg=cfg_scale + guider2_obj = _unwrap( + _call_node(nodes_map["CFGGuider"], + model=diff_model, positive=crop_pos, negative=crop_neg, cfg=cfg_scale), + 0, ) - guider2_obj = guider2.output[0] if hasattr(guider2, "output") else guider2[0] - sampled2 = nodes_map["SamplerCustomAdvanced"].execute( - noise=noise2_obj, guider=guider2_obj, sampler=sampler2_obj, - sigmas=sigmas2_tensor, latent_image=stage2_input_latent, + sampled2_latent = _unwrap( + _call_node(nodes_map["SamplerCustomAdvanced"], + noise=noise2_obj, guider=guider2_obj, sampler=sampler2_obj, + sigmas=sigmas2_tensor, latent_image=stage2_input_latent), + 0, ) - sampled2_latent = sampled2.output[0] if hasattr(sampled2, "output") else sampled2[0] # 11. Separate AV final - sep2 = nodes_map["LTXVSeparateAVLatent"].execute(av_latent=sampled2_latent) - if hasattr(sep2, "output"): - video2, audio2 = sep2.output[0], sep2.output[1] - else: - video2, audio2 = sep2[0], sep2[1] + sep2 = _call_node(nodes_map["LTXVSeparateAVLatent"], av_latent=sampled2_latent) + video2, audio2 = _unwrap(sep2, 0), _unwrap(sep2, 1) # 12. Decode video (tiled - non-tiled OOMs at typical durations) - dec = nodes_map["VAEDecodeTiled"].execute( - samples=video2, vae=video_vae, - tile_size=768, overlap=64, temporal_size=4096, temporal_overlap=4, + frames_tensor = _unwrap( + _call_node(nodes_map["VAEDecodeTiled"], + samples=video2, vae=video_vae, + tile_size=768, overlap=64, temporal_size=4096, temporal_overlap=4), + 0, ) - frames_tensor = dec.output[0] if hasattr(dec, "output") else dec[0] # 13. Decode audio (Phase A: always on) - adec = nodes_map["LTXVAudioVAEDecode"].execute(samples=audio2, audio_vae=audio_vae) - audio_waveform = adec.output[0] if hasattr(adec, "output") else adec[0] + audio_waveform = _unwrap( + _call_node(nodes_map["LTXVAudioVAEDecode"], samples=audio2, audio_vae=audio_vae), + 0, + ) # 14. Create video container - cv = nodes_map["CreateVideo"].execute( - fps=frame_rate, images=frames_tensor, audio=audio_waveform + video_obj = _unwrap( + _call_node(nodes_map["CreateVideo"], fps=frame_rate, images=frames_tensor, audio=audio_waveform), + 0, ) - video_obj = cv.output[0] if hasattr(cv, "output") else cv[0] # 15. Save mp4 if not output_path.lower().endswith(".mp4"): @@ -547,7 +552,8 @@ def ltx_video_generate(config, ltx_models, output_path): # SaveVideo's "filename_prefix" can be a path. The node appends a # timestamp/index by default - we control naming so we strip those. # Use a temporary save then rename to enforce our exact filename. - nodes_map["SaveVideo"].execute( + _call_node( + nodes_map["SaveVideo"], filename_prefix=os.path.join(out_dir, out_name + "_tmp"), format="mp4", codec="auto",