Files
BiyikgokhanandClaude Opus 4.6 bf31dde091 Initial release: InfiniTalk AutoScale custom node
Single ComfyUI node that auto-scales Wan2.1 InfiniTalk video generation
to any audio duration. No manual segment chaining needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:30:44 +03:00

300 lines
12 KiB
Python

"""
InfiniTalk AutoScale — single ComfyUI node that auto-scales to any audio duration.
Internally loops N segments based on audio length, handling:
- Audio embedding windowing per segment
- Motion frame overlap between segments
- Latent sampling + VAE decode per segment
- Frame accumulation with overlap trimming
Canvas usage:
[LoadImage] ─────────┐
[LoadAudio] ─► [AudioEncoderEncode] ─┐
[UNETLoader] ─► [LoRA] ─┐ │
[ModelPatchLoader] ──────┤ │
[CLIPTextEncode] ────────┤ │
[ConditioningZeroOut] ───┤ │
[VAELoader] ─────────────┤ │
▼ ▼
[InfiniTalkAutoScale]
│
▼ IMAGE
[CreateVideo] ─► [SaveVideo]
"""
import logging
import torch
import comfy.model_management
import comfy.patcher_extension
import comfy.sample
import comfy.samplers
import comfy.utils
import latent_preview
import node_helpers
from comfy.ldm.wan.model_multitalk import (
InfiniteTalkOuterSampleWrapper,
MultiTalkCrossAttnPatch,
MultiTalkGetAttnMapPatch,
project_audio_features,
)
from comfy_extras.nodes_wan import linear_interpolation
logger = logging.getLogger("InfiniTalkAutoScale")
class InfiniTalkAutoScale:
"""Single node that generates a full InfiniTalk video, auto-scaled to audio duration."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"model_patch": ("MODEL_PATCH",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"vae": ("VAE",),
"audio_encoder_output_1": ("AUDIO_ENCODER_OUTPUT",),
"start_image": ("IMAGE",),
"width": ("INT", {"default": 832, "min": 16, "max": 16384, "step": 16}),
"height": ("INT", {"default": 480, "min": 16, "max": 16384, "step": 16}),
"frame_window": ("INT", {
"default": 81, "min": 17, "max": 257, "step": 4,
"tooltip": "Frames per segment (must be 4n+1). 81 = ~3.2s per segment.",
}),
"motion_frame_count": ("INT", {
"default": 9, "min": 1, "max": 33, "step": 1,
"tooltip": "Overlap frames between segments for temporal continuity.",
}),
"max_segments": ("INT", {
"default": 0, "min": 0, "max": 100, "step": 1,
"tooltip": "Max segments (0 = auto from audio length, no limit).",
}),
"steps": ("INT", {"default": 4, "min": 1, "max": 100}),
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.1}),
"sampler_name": (comfy.samplers.SAMPLER_NAMES,),
"scheduler": (comfy.samplers.SCHEDULER_NAMES,),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
"audio_scale": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("video_frames",)
FUNCTION = "generate"
CATEGORY = "video/infinitalk"
OUTPUT_NODE = False
DESCRIPTION = (
"Auto-scaling InfiniTalk video generation. "
"Determines segment count from audio duration and loops internally."
)
# ── helpers ────────────────────────────────────────────────────────────
@staticmethod
def _process_audio_embeddings(audio_encoder_output):
"""Convert raw audio encoder output to frame-level embeddings. Returns (encoded_audio, total_frames)."""
all_layers = audio_encoder_output["encoded_audio_all_layers"]
encoded_audio = torch.stack(all_layers, dim=0).squeeze(1)[1:] # [num_layers, T, 512]
encoded_audio = linear_interpolation(encoded_audio, input_fps=50, output_fps=25) # [num_layers, T', 512]
encoded_audio = encoded_audio.movedim(0, 1) # [T', num_layers, 512]
total_frames = encoded_audio.shape[0]
return encoded_audio, total_frames
@staticmethod
def _calc_segments(total_audio_frames, frame_window, motion_frame_count, max_segments):
"""Calculate how many segments are needed to cover the audio."""
new_frames_per_seg = frame_window - motion_frame_count
if total_audio_frames <= frame_window:
n = 1
else:
n = 1 + max(1, -(-((total_audio_frames - frame_window)) // new_frames_per_seg)) # ceil div
if max_segments > 0:
n = min(n, max_segments)
return n
def _prepare_segment(self, model, model_patch, positive, negative, vae,
encoded_audio_list, start_image, previous_frames,
width, height, length, motion_frame_count,
audio_start, audio_end, audio_scale):
"""Replicates WanInfiniteTalkToVideo.execute() logic for one segment."""
if previous_frames is not None and previous_frames.shape[0] < motion_frame_count:
raise ValueError("Not enough previous frames for motion context.")
latent = torch.zeros(
[1, 16, ((length - 1) // 4) + 1, height // 8, width // 8],
device=comfy.model_management.intermediate_device(),
)
if start_image is not None:
si = comfy.utils.common_upscale(
start_image[:length].movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
image = torch.ones(
(length, height, width, si.shape[-1]), device=si.device, dtype=si.dtype
) * 0.5
image[:si.shape[0]] = si
concat_latent_image = vae.encode(image[:, :, :, :3])
concat_mask = torch.ones(
(1, 1, latent.shape[2], concat_latent_image.shape[-2], concat_latent_image.shape[-1]),
device=si.device, dtype=si.dtype,
)
concat_mask[:, :, :((si.shape[0] - 1) // 4) + 1] = 0.0
positive = node_helpers.conditioning_set_values(
positive, {"concat_latent_image": concat_latent_image, "concat_mask": concat_mask}
)
negative = node_helpers.conditioning_set_values(
negative, {"concat_latent_image": concat_latent_image, "concat_mask": concat_mask}
)
model_patched = model.clone()
if previous_frames is not None:
motion_frames = comfy.utils.common_upscale(
previous_frames[-motion_frame_count:].movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
motion_frames_latent = vae.encode(motion_frames[:, :, :, :3])
trim_image = motion_frame_count
else:
motion_frames_latent = concat_latent_image[:, :, :1]
trim_image = 0
audio_embed = project_audio_features(
model_patch.model.audio_proj, encoded_audio_list, audio_start, audio_end
).to(model_patched.model_dtype())
model_patched.model_options["transformer_options"]["audio_embeds"] = audio_embed
model_patched.add_wrapper_with_key(
comfy.patcher_extension.WrappersMP.OUTER_SAMPLE,
"infinite_talk_outer_sample",
InfiniteTalkOuterSampleWrapper(
motion_frames_latent,
model_patch,
is_extend=previous_frames is not None,
),
)
model_patched.set_model_patch(MultiTalkCrossAttnPatch(model_patch, audio_scale), "attn2_patch")
out_latent = {"samples": latent}
return model_patched, positive, negative, out_latent, trim_image
def _sample_segment(self, model_patched, positive, negative, latent_dict,
sampler_name, scheduler, steps, cfg, seed):
"""Run CFGGuider + SamplerCustomAdvanced logic for one segment."""
guider = comfy.samplers.CFGGuider(model_patched)
guider.set_conds(positive, negative)
guider.set_cfg(cfg)
sampler = comfy.samplers.sampler_object(sampler_name)
sigmas = comfy.samplers.calculate_sigmas(
model_patched.get_model_object("model_sampling"), scheduler, steps
).cpu()
latent = latent_dict.copy()
latent_image = latent["samples"]
latent_image = comfy.sample.fix_empty_latent_channels(guider.model_patcher, latent_image, None)
latent["samples"] = latent_image
noise = comfy.sample.prepare_noise(latent_image, seed)
callback = latent_preview.prepare_callback(guider.model_patcher, sigmas.shape[-1] - 1)
samples = guider.sample(
noise, latent_image, sampler, sigmas,
denoise_mask=None, callback=callback,
disable_pbar=not comfy.utils.PROGRESS_BAR_ENABLED,
seed=seed,
)
samples = samples.to(comfy.model_management.intermediate_device())
return {"samples": samples}
# ── main entry ─────────────────────────────────────────────────────────
def generate(self, model, model_patch, positive, negative, vae,
audio_encoder_output_1, start_image, width, height,
frame_window, motion_frame_count, max_segments,
steps, cfg, sampler_name, scheduler, seed, audio_scale):
encoded_audio, total_audio_frames = self._process_audio_embeddings(audio_encoder_output_1)
encoded_audio_list = [encoded_audio]
num_segments = self._calc_segments(total_audio_frames, frame_window, motion_frame_count, max_segments)
total_video_frames = frame_window + (num_segments - 1) * (frame_window - motion_frame_count)
audio_duration = total_audio_frames / 25.0
video_duration = total_video_frames / 25.0
logger.info(
f"InfiniTalkAutoScale: audio={audio_duration:.1f}s ({total_audio_frames} frames) "
f"-> {num_segments} segments -> ~{video_duration:.1f}s video"
)
pbar = comfy.utils.ProgressBar(num_segments)
all_frames = []
previous_frames = None
audio_offset = 0
for seg_idx in range(num_segments):
seg_seed = seed + seg_idx
audio_start = audio_offset
audio_end = audio_start + frame_window
audio_end = min(audio_end, total_audio_frames + frame_window)
logger.info(f" Segment {seg_idx + 1}/{num_segments}: audio frames {audio_start}-{audio_end}, seed={seg_seed}")
model_patched, pos, neg, latent_dict, trim_image = self._prepare_segment(
model=model,
model_patch=model_patch,
positive=positive,
negative=negative,
vae=vae,
encoded_audio_list=encoded_audio_list,
start_image=start_image,
previous_frames=previous_frames,
width=width, height=height,
length=frame_window,
motion_frame_count=motion_frame_count,
audio_start=audio_start,
audio_end=audio_end,
audio_scale=audio_scale,
)
sampled = self._sample_segment(
model_patched, pos, neg, latent_dict,
sampler_name, scheduler, steps, cfg, seg_seed,
)
decoded = vae.decode(sampled["samples"])
if len(decoded.shape) == 5:
decoded = decoded.reshape(-1, decoded.shape[-3], decoded.shape[-2], decoded.shape[-1])
if seg_idx == 0:
all_frames.append(decoded)
else:
all_frames.append(decoded[trim_image:])
previous_frames = decoded
audio_offset += (frame_window - motion_frame_count)
pbar.update(1)
final_frames = torch.cat(all_frames, dim=0)
logger.info(f"InfiniTalkAutoScale: Done! {final_frames.shape[0]} total frames")
return (final_frames,)
NODE_CLASS_MAPPINGS = {
"InfiniTalkAutoScale": InfiniTalkAutoScale,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"InfiniTalkAutoScale": "InfiniTalk AutoScale",
}