Files
XinPeng76 d0b104935c feat: 添加TJSCustom节点(SamplerCustom + TJS)
- 新增TJSCustom节点,镜像ComfyUI原生SamplerCustom接口
- 支持model/add_noise/noise_seed/cfg/positive/negative/sampler/sigmas/latent_image输入
- 额外增加early_exit_gamma参数控制TJS早退
- 使用单次sample_custom调用,通过callback捕获x0和xt
- 更新__init__.py、nodes.py和README.md
2026-07-13 23:02:17 +08:00

1248 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ComfyUI nodes for Truncated Jump Sampling (TJS).
TJS runs the ordinary sampler for k* early steps, then asks the model for the
endpoint prediction at the early-exit state. In ComfyUI/k-diffusion terms that
endpoint prediction is the model's `denoised` latent at sigma*.
Nodes provided:
- TJSSampler — one-shot TJS sampler (gamma-based early exit)
- TJSAdvancedSampler — KSampler-Advanced variant with TJS endpoint decode
- TJSCustomAdvanced — SamplerCustomAdvanced variant (guider/sampler/sigmas inputs)
- TJSDecode — endpoint decode for a KSampler-Advanced leftover latent
- TJSDecodeManualSigma — endpoint decode with an explicit sigma (debug)
"""
import math
import torch
import comfy.model_management
import comfy.sample
import comfy.samplers
try:
import comfy.utils
except Exception: # pragma: no cover - older ComfyUI builds may differ.
pass
import latent_preview
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _progress_enabled():
utils = getattr(comfy, "utils", None)
return bool(getattr(utils, "PROGRESS_BAR_ENABLED", True))
def _sampler_object(name):
if hasattr(comfy.samplers, "sampler_object"):
return comfy.samplers.sampler_object(name)
return comfy.samplers.ksampler(name)
def _calculate_sigmas(model, steps, sampler_name, scheduler, denoise):
"""Compute the full sigma schedule, matching KSampler's internal logic.
This handles ``DISCARD_PENULTIMATE_SIGMA_SAMPLERS`` (dpm_2, uni_pc, …)
identically to ``comfy.samplers.KSampler``, so the returned sigma array
is directly comparable to what KSampler / KSamplerAdvanced would use.
"""
device = comfy.model_management.get_torch_device()
if hasattr(comfy.samplers, "KSampler"):
ks = comfy.samplers.KSampler(
model, steps, device,
sampler=sampler_name, scheduler=scheduler, denoise=denoise,
)
return ks.sigmas.to(device)
# Fallback for very old ComfyUI builds without comfy.samplers.KSampler.
from comfy_extras.nodes_custom_sampler import BasicScheduler
return BasicScheduler().get_sigmas(model, scheduler, steps, denoise)[0].to(device)
def _fix_latent(model, latent):
fixed = latent.copy()
fixed["samples"] = comfy.sample.fix_empty_latent_channels(model, latent["samples"])
return fixed
def _prepare_noise(latent, seed):
samples = latent["samples"]
batch_inds = latent.get("batch_index")
if hasattr(comfy.sample, "prepare_noise"):
return comfy.sample.prepare_noise(samples, seed, batch_inds)
generator = torch.Generator("cpu").manual_seed(seed)
return torch.randn(
samples.size(),
dtype=samples.dtype,
layout=samples.layout,
generator=generator,
device="cpu",
)
def _zeros_like_noise(latent_samples):
return torch.zeros(
latent_samples.size(),
dtype=latent_samples.dtype,
layout=latent_samples.layout,
device="cpu",
)
def _sample_custom(
model,
noise,
cfg,
sampler,
sigmas,
positive,
negative,
latent_image,
noise_mask=None,
callback=None,
seed=0,
):
"""Call ``comfy.sample.sample_custom`` (or its older equivalent)."""
disable_pbar = not _progress_enabled()
if hasattr(comfy.sample, "sample_custom"):
try:
return comfy.sample.sample_custom(
model,
noise,
cfg,
sampler,
sigmas,
positive,
negative,
latent_image,
noise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
)
except TypeError as exc:
if "noise_mask" not in str(exc):
raise
return comfy.sample.sample_custom(
model,
noise,
cfg,
sampler,
sigmas,
positive,
negative,
latent_image,
denoise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
)
device = comfy.model_management.get_torch_device()
return comfy.samplers.sample(
model,
noise,
positive,
negative,
cfg,
device,
sampler,
sigmas,
latent_image=latent_image,
denoise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
)
def _sample_standard(
model,
latent,
seed,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
denoise,
):
"""Mirror ComfyUI's normal KSampler path for the full-schedule case."""
latent_samples = latent["samples"]
noise = _prepare_noise(latent, seed)
noise_mask = latent.get("noise_mask")
callback = latent_preview.prepare_callback(model, steps)
disable_pbar = not _progress_enabled()
try:
return comfy.sample.sample(
model,
noise,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
latent_samples,
denoise=denoise,
noise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
)
except TypeError as exc:
if "noise_mask" not in str(exc):
raise
return comfy.sample.sample(
model,
noise,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
latent_samples,
denoise=denoise,
denoise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
)
def _sample_advanced(
model,
latent,
seed,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
disable_noise=False,
start_step=None,
last_step=None,
force_full_denoise=False,
):
"""Run KSampler-Advanced-style sampling with start / stop step control.
Mirrors ``common_ksampler`` + ``comfy.sample.sample`` but exposes the
``start_step`` / ``last_step`` / ``force_full_denoise`` / ``disable_noise``
parameters that KSamplerAdvanced uses.
"""
latent_samples = latent["samples"]
if disable_noise:
noise = _zeros_like_noise(latent_samples)
else:
noise = _prepare_noise(latent, seed)
noise_mask = latent.get("noise_mask")
# Progress-bar step count: the actual number of steps that will run.
cb_total = steps
if last_step is not None and last_step < steps:
cb_total = max(1, last_step - (start_step or 0))
callback = latent_preview.prepare_callback(model, cb_total)
disable_pbar = not _progress_enabled()
try:
return comfy.sample.sample(
model,
noise,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
latent_samples,
denoise=1.0,
disable_noise=disable_noise,
start_step=start_step,
last_step=last_step,
force_full_denoise=force_full_denoise,
noise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
)
except TypeError as exc:
if "noise_mask" not in str(exc):
raise
return comfy.sample.sample(
model,
noise,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
latent_samples,
denoise=1.0,
disable_noise=disable_noise,
start_step=start_step,
last_step=last_step,
force_full_denoise=force_full_denoise,
denoise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
)
def _callback_x0_to_latent(model, x0_output, fallback_samples):
"""Extract the denoised x0 captured by the callback, or fall back."""
if "x0" not in x0_output:
return fallback_samples
return model.model.process_latent_out(x0_output["x0"].detach().cpu())
def _make_tjs_callback(model, total_steps, capture_step, x0_output, xt_output):
"""Create a callback that wraps latent_preview's callback and captures xt.
The k-diffusion callback provides ``denoised`` (x0 prediction) and ``x``
(current state before step) at each step. ``latent_preview.prepare_callback``
already stores the last step's ``denoised`` in *x0_output*. This wrapper
additionally saves ``x`` at *capture_step* (the endpoint-decode step) into
*xt_output*, so both latent_x0 and latent_xt are captured in a single pass.
"""
base_callback = latent_preview.prepare_callback(model, total_steps, x0_output)
def callback(step, x0, x, total):
base_callback(step, x0, x, total)
if step == capture_step:
xt_output["xt"] = x.detach().clone()
return callback
# ---------------------------------------------------------------------------
# TJSSampler (existing one-shot node, kept compatible)
# ---------------------------------------------------------------------------
class TJSSampler:
"""One-shot TJS sampler with gamma-based early exit.
Runs a single sampling call with sigmas ``[sigma_0, ..., sigma_{k*}, 0]``
where ``k* = ceil(gamma * steps)``. The appended 0 makes the sampler's
last step the endpoint decode, and the callback captures both x0 (the
model's denoised prediction at sigma*) and xt (the state at sigma*) in
a single pass — no separate endpoint-decode call needed.
When ``early_exit_gamma >= 1.0`` the full schedule is used (identical to
the standard KSampler).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"total_steps": ("INT", {"default": 30, "min": 2, "max": 10000}),
"early_exit_gamma": (
"FLOAT",
{
"default": 0.6,
"min": 0.05,
"max": 1.0,
"step": 0.05,
"display": "slider",
},
),
"cfg": (
"FLOAT",
{"default": 7.5, "min": 0.0, "max": 100.0, "step": 0.1},
),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"latent_image": ("LATENT",),
"seed": (
"INT",
{"default": 0, "min": 0, "max": 0xffffffffffffffff},
),
"model_type": (
["auto", "diffusion", "flow"],
{"default": "auto"},
),
},
"optional": {
"denoise": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01},
),
},
}
RETURN_TYPES = ("LATENT", "LATENT", "INT", "INT", "FLOAT", "FLOAT")
RETURN_NAMES = (
"latent_x0",
"latent_xt",
"k_star",
"nfe_used",
"nfe_saving_pct",
"sigma_at_exit",
)
FUNCTION = "sample"
CATEGORY = "sampling/TJS"
def sample(
self,
model,
total_steps,
early_exit_gamma,
cfg,
sampler_name,
scheduler,
positive,
negative,
latent_image,
seed,
model_type="auto",
denoise=1.0,
):
latent = _fix_latent(model, latent_image)
gamma = float(early_exit_gamma)
# Full-schedule fast path (gamma ≈ 1.0): identical to KSampler.
if gamma >= 0.999999:
samples = _sample_standard(
model, latent, seed, total_steps, cfg,
sampler_name, scheduler, positive, negative, denoise,
)
out_x0 = latent.copy()
out_x0["samples"] = samples
out_xt = latent.copy()
out_xt["samples"] = samples
print(
"[ComfyUI_TJS] "
f"K={total_steps} gamma={early_exit_gamma:.3f} "
"full-schedule fast path "
f"NFE={total_steps} saving=0.0% mode={model_type}"
)
return (out_x0, out_xt, total_steps, total_steps, 0.0, 0.0)
latent_samples = latent["samples"]
noise_mask = latent.get("noise_mask")
sigmas = _calculate_sigmas(model, total_steps, sampler_name, scheduler, denoise)
actual_steps = max(1, int(sigmas.shape[-1]) - 1)
k_star = max(1, math.ceil(gamma * total_steps))
k_star = min(k_star, actual_steps)
# If k* reaches the end of the schedule, use the full-schedule path
# (appending 0 to sigmas that already end with 0 wastes an NFE).
if k_star >= actual_steps:
samples = _sample_standard(
model, latent, seed, total_steps, cfg,
sampler_name, scheduler, positive, negative, denoise,
)
out_x0 = latent.copy()
out_x0["samples"] = samples
out_xt = latent.copy()
out_xt["samples"] = samples
print(
"[ComfyUI_TJS] "
f"K={total_steps} gamma={early_exit_gamma:.3f} "
f"k*={k_star}>=actual_steps={actual_steps} "
"full-schedule path "
f"NFE={total_steps} saving=0.0% mode={model_type}"
)
return (out_x0, out_xt, total_steps, total_steps, 0.0, 0.0)
sigma_star = float(sigmas[k_star].detach().cpu())
# Single-call TJS: append 0 to truncated sigmas so the sampler's
# last step (sigma* → 0) is the endpoint decode. The callback
# captures both x0 (denoised) and xt (state at sigma*) in one pass,
# eliminating the overhead of a separate endpoint-decode call.
tjs_sigmas = torch.cat([
sigmas[: k_star + 1],
torch.zeros(1, dtype=sigmas.dtype, device=sigmas.device),
])
noise = _prepare_noise(latent, seed)
sampler = _sampler_object(sampler_name)
x0_output = {}
xt_output = {}
callback = _make_tjs_callback(
model, k_star + 1, k_star, x0_output, xt_output,
)
samples = _sample_custom(
model,
noise,
cfg,
sampler,
tjs_sigmas,
positive,
negative,
latent_samples,
noise_mask=noise_mask,
callback=callback,
seed=seed,
)
latent_x0 = _callback_x0_to_latent(model, x0_output, samples)
if "xt" in xt_output:
latent_xt = model.model.process_latent_out(
xt_output["xt"].detach().cpu()
)
else:
latent_xt = samples
nfe_used = k_star + 1
nfe_saving = max(0.0, (1.0 - nfe_used / float(total_steps)) * 100.0)
out_x0 = latent.copy()
out_x0["samples"] = latent_x0
out_xt = latent.copy()
out_xt["samples"] = latent_xt
print(
"[ComfyUI_TJS] "
f"K={total_steps} gamma={early_exit_gamma:.3f} "
f"k*={k_star} sigma*={sigma_star:.6g} "
f"NFE={nfe_used} saving={nfe_saving:.1f}% mode={model_type}"
)
return (out_x0, out_xt, k_star, nfe_used, round(nfe_saving, 1), sigma_star)
# ---------------------------------------------------------------------------
# TJSAdvancedSampler (new — KSampler Advanced + TJS endpoint decode)
# ---------------------------------------------------------------------------
class TJSAdvancedSampler:
"""KSampler-Advanced variant with built-in TJS endpoint decode.
Runs a single sampling call with sigmas ``[sigma_start, ..., sigma_{k*}, 0]``
where the appended 0 makes the last step the endpoint decode. The callback
captures both x0 (denoised at sigma*) and xt (state at sigma*) in one pass,
avoiding the overhead of a separate endpoint-decode sampling call.
Supports ``add_noise = disable`` for img2img and ``start_at_step > 0`` for
multi-stage workflows — just like KSamplerAdvanced, but with TJS early-exit
built in.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"add_noise": (["enable", "disable"], {"advanced": True}),
"noise_seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
"control_after_generate": True,
},
),
"steps": ("INT", {"default": 30, "min": 1, "max": 10000}),
"early_exit_gamma": (
"FLOAT",
{
"default": 0.6,
"min": 0.05,
"max": 1.0,
"step": 0.05,
"display": "slider",
},
),
"cfg": (
"FLOAT",
{"default": 8.0, "min": 0.0, "max": 100.0, "step": 0.1, "round": 0.01},
),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"latent_image": ("LATENT",),
"start_at_step": (
"INT",
{"default": 0, "min": 0, "max": 10000, "advanced": True},
),
"model_type": (
["auto", "diffusion", "flow"],
{"default": "auto"},
),
},
}
RETURN_TYPES = ("LATENT", "LATENT", "INT", "INT", "FLOAT", "FLOAT")
RETURN_NAMES = (
"latent_x0",
"latent_xt",
"k_star",
"nfe_used",
"nfe_saving_pct",
"sigma_at_exit",
)
FUNCTION = "sample"
CATEGORY = "sampling/TJS"
def sample(
self,
model,
add_noise,
noise_seed,
steps,
early_exit_gamma,
cfg,
sampler_name,
scheduler,
positive,
negative,
latent_image,
start_at_step,
model_type="auto",
):
latent = _fix_latent(model, latent_image)
gamma = float(early_exit_gamma)
disable_noise = add_noise == "disable"
# Full sigma schedule (identical to what KSamplerAdvanced computes).
sigmas = _calculate_sigmas(model, steps, sampler_name, scheduler, 1.0)
actual_steps = max(1, int(sigmas.shape[-1]) - 1)
# k* = ceil(gamma * steps), clamped to the valid range.
k_star = max(1, math.ceil(gamma * steps))
k_star = min(k_star, actual_steps)
# If start_at_step is beyond k*, adjust so at least one step runs.
if start_at_step >= k_star:
print(
"[ComfyUI_TJS] WARNING: start_at_step >= k*, "
f"adjusting start_at_step to 0 (was {start_at_step}, k*={k_star})"
)
start_at_step = 0
# ---- Full-schedule fast path (gamma ≈ 1.0) ----------------------
if gamma >= 0.999999 or k_star >= actual_steps:
samples = _sample_advanced(
model,
latent,
noise_seed,
steps,
cfg,
sampler_name,
scheduler,
positive,
negative,
disable_noise=disable_noise,
start_step=start_at_step,
last_step=None,
force_full_denoise=True,
)
out_x0 = latent.copy()
out_x0["samples"] = samples
out_xt = latent.copy()
out_xt["samples"] = samples
print(
"[ComfyUI_TJS] Advanced "
f"K={steps} gamma={gamma:.3f} "
f"start={start_at_step} full-schedule "
f"NFE={actual_steps} saving=0.0% mode={model_type}"
)
return (out_x0, out_xt, actual_steps, actual_steps, 0.0, 0.0)
# ---- Truncated TJS path (single-call) ---------------------------
sigma_star = float(sigmas[k_star].detach().cpu())
# Append 0 to truncated sigmas: [sigma_start, ..., sigma*, 0].
# The sampler's last step (sigma* → 0) is the endpoint decode,
# captured via callback — no separate sampling call needed.
tjs_sigmas = torch.cat([
sigmas[start_at_step : k_star + 1],
torch.zeros(1, dtype=sigmas.dtype, device=sigmas.device),
])
latent_samples = latent["samples"]
noise_mask = latent.get("noise_mask")
if disable_noise:
noise = _zeros_like_noise(latent_samples)
else:
noise = _prepare_noise(latent, noise_seed)
sampler = _sampler_object(sampler_name)
tjs_steps = k_star + 1 - start_at_step # truncated + endpoint
x0_output = {}
xt_output = {}
callback = _make_tjs_callback(
model, tjs_steps, tjs_steps - 1, x0_output, xt_output,
)
samples = _sample_custom(
model,
noise,
cfg,
sampler,
tjs_sigmas,
positive,
negative,
latent_samples,
noise_mask=noise_mask,
callback=callback,
seed=noise_seed,
)
latent_x0 = _callback_x0_to_latent(model, x0_output, samples)
if "xt" in xt_output:
latent_xt = model.model.process_latent_out(
xt_output["xt"].detach().cpu()
)
else:
latent_xt = samples
steps_run = k_star - start_at_step
nfe_used = tjs_steps # = steps_run + 1
nfe_saving = max(0.0, (1.0 - nfe_used / float(actual_steps)) * 100.0)
out_x0 = latent.copy()
out_x0["samples"] = latent_x0
out_xt = latent.copy()
out_xt["samples"] = latent_xt
print(
"[ComfyUI_TJS] Advanced "
f"K={steps} gamma={gamma:.3f} "
f"k*={k_star} sigma*={sigma_star:.6g} "
f"start={start_at_step} steps_run={steps_run} "
f"NFE={nfe_used} saving={nfe_saving:.1f}% mode={model_type}"
)
return (out_x0, out_xt, k_star, nfe_used, round(nfe_saving, 1), sigma_star)
# ---------------------------------------------------------------------------
# TJSCustomAdvanced (new — SamplerCustomAdvanced + TJS endpoint decode)
# ---------------------------------------------------------------------------
class TJSCustomAdvanced:
"""TJS for the "custom advanced sampler" paradigm.
Mirrors ComfyUI's built-in ``SamplerCustomAdvanced`` node (the one under
"自定义采样器(高级)" / "sampler > custom"), but adds TJS early-exit:
k* = ceil(gamma * (len(sigmas) - 1))
The node runs the guider with sigmas ``[sigma_0, ..., sigma_{k*}, 0]`` —
the appended 0 makes the sampler's last step the endpoint decode, and the
callback captures both x0 (denoised) and xt (state at sigma*) in a single
pass. This avoids the overhead of a second ``guider.sample()`` call.
Inputs are identical to SamplerCustomAdvanced (noise, guider, sampler,
sigmas, latent_image) plus ``early_exit_gamma``.
Workflow::
BasicScheduler → sigmas
CFGGuider → guider
KSamplerSelect → sampler
RandomNoise → noise
noise + guider + sampler + sigmas + latent_image
→ TJSCustomAdvanced
→ latent_x0 (endpoint decoded, feed to VAE Decode)
→ latent_xt (truncated sampling output)
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"noise": ("NOISE", {"tooltip": "Noise source (e.g. Random Noise)."}),
"guider": ("GUIDER", {"tooltip": "Guider object (e.g. CFGGuider, BasicGuider)."}),
"sampler": ("SAMPLER", {"tooltip": "Sampler object (e.g. from KSamplerSelect)."}),
"sigmas": ("SIGMAS", {"tooltip": "Sigma schedule (e.g. from BasicScheduler)."}),
"latent_image": ("LATENT",),
"early_exit_gamma": (
"FLOAT",
{
"default": 0.6,
"min": 0.05,
"max": 1.0,
"step": 0.05,
"display": "slider",
"tooltip": "TJS early-exit ratio. k* = ceil(gamma * steps).",
},
),
},
}
RETURN_TYPES = ("LATENT", "LATENT", "INT", "INT", "FLOAT", "FLOAT")
RETURN_NAMES = (
"latent_x0",
"latent_xt",
"k_star",
"nfe_used",
"nfe_saving_pct",
"sigma_at_exit",
)
FUNCTION = "sample"
CATEGORY = "sampling/TJS"
def sample(self, noise, guider, sampler, sigmas, latent_image, early_exit_gamma):
gamma = float(early_exit_gamma)
# Fix latent channels using the guider's model_patcher.
model_patcher = guider.model_patcher
latent = latent_image.copy()
latent_samples = comfy.sample.fix_empty_latent_channels(
model_patcher, latent["samples"],
)
latent["samples"] = latent_samples
noise_mask = latent.get("noise_mask")
# Handle empty sigmas.
sigmas_list = sigmas.tolist() if hasattr(sigmas, "tolist") else list(sigmas)
if len(sigmas_list) == 0:
out = latent.copy()
print("[ComfyUI_TJS] Custom Advanced: empty sigmas, returning input")
return (out, out, 0, 0, 0.0, 0.0)
total_steps = len(sigmas_list) - 1
if total_steps <= 0:
out = latent.copy()
print("[ComfyUI_TJS] Custom Advanced: no steps to run")
return (out, out, 0, 0, 0.0, 0.0)
disable_pbar = not _progress_enabled()
# ---- Full-schedule fast path (gamma ≈ 1.0) ----------------------
if gamma >= 0.999999 or total_steps <= 1:
noise_tensor = noise.generate_noise(latent)
x0_output = {}
callback = latent_preview.prepare_callback(
model_patcher, total_steps, x0_output,
)
samples = guider.sample(
noise_tensor, latent_samples, sampler, sigmas,
denoise_mask=noise_mask, callback=callback,
disable_pbar=disable_pbar, seed=noise.seed,
)
samples = samples.to(comfy.model_management.intermediate_device())
out_xt = latent.copy()
out_xt["samples"] = samples
if "x0" in x0_output:
x0_out = model_patcher.model.process_latent_out(
x0_output["x0"].detach().cpu(),
)
out_x0 = latent.copy()
out_x0["samples"] = x0_out
else:
out_x0 = out_xt
out_x0.pop("downscale_ratio_spacial", None)
out_x0.pop("downscale_ratio_temporal", None)
out_xt.pop("downscale_ratio_spacial", None)
out_xt.pop("downscale_ratio_temporal", None)
print(
f"[ComfyUI_TJS] Custom Advanced: full-schedule "
f"K={total_steps} NFE={total_steps}"
)
return (out_x0, out_xt, total_steps, total_steps, 0.0, 0.0)
# ---- Compute k* and sigma* --------------------------------------
k_star = max(1, math.ceil(gamma * total_steps))
k_star = min(k_star, total_steps)
# If k* reaches the end of the schedule, the appended 0 would be
# redundant (sigmas already ends with 0). Use the full-schedule
# path instead to avoid wasting an NFE on a σ=0 → σ=0 step.
if k_star >= total_steps:
noise_tensor = noise.generate_noise(latent)
x0_output = {}
callback = latent_preview.prepare_callback(
model_patcher, total_steps, x0_output,
)
samples = guider.sample(
noise_tensor, latent_samples, sampler, sigmas,
denoise_mask=noise_mask, callback=callback,
disable_pbar=disable_pbar, seed=noise.seed,
)
samples = samples.to(comfy.model_management.intermediate_device())
out_xt = latent.copy()
out_xt["samples"] = samples
if "x0" in x0_output:
x0_out = model_patcher.model.process_latent_out(
x0_output["x0"].detach().cpu(),
)
out_x0 = latent.copy()
out_x0["samples"] = x0_out
else:
out_x0 = out_xt
out_x0.pop("downscale_ratio_spacial", None)
out_x0.pop("downscale_ratio_temporal", None)
out_xt.pop("downscale_ratio_spacial", None)
out_xt.pop("downscale_ratio_temporal", None)
print(
f"[ComfyUI_TJS] Custom Advanced: full-schedule "
f"K={total_steps} NFE={total_steps}"
)
return (out_x0, out_xt, total_steps, total_steps, 0.0, 0.0)
sigma_star = float(sigmas_list[k_star])
# ---- Single-call TJS (truncated + endpoint decode) -------------
# Append 0 to truncated sigmas so the sampler's last step
# (sigma* → 0) is the endpoint decode, captured via callback.
# This eliminates the overhead of a second guider.sample() call
# (model loading, condition preparation, cleanup) for what is
# just a single forward pass.
tjs_sigmas = torch.cat([
sigmas[:k_star + 1],
torch.zeros(1, dtype=sigmas.dtype, device=sigmas.device),
])
noise_tensor = noise.generate_noise(latent)
x0_output = {}
xt_output = {}
callback = _make_tjs_callback(
model_patcher, k_star + 1, k_star, x0_output, xt_output,
)
samples = guider.sample(
noise_tensor, latent_samples, sampler, tjs_sigmas,
denoise_mask=noise_mask, callback=callback,
disable_pbar=disable_pbar, seed=noise.seed,
)
samples = samples.to(comfy.model_management.intermediate_device())
latent_x0_tensor = _callback_x0_to_latent(
model_patcher, x0_output, samples,
)
if "xt" in xt_output:
latent_xt_tensor = model_patcher.model.process_latent_out(
xt_output["xt"].detach().cpu(),
)
else:
latent_xt_tensor = samples
nfe_used = k_star + 1
nfe_saving = max(0.0, (1.0 - nfe_used / float(total_steps)) * 100.0)
# Build output dicts.
out_x0 = latent.copy()
out_x0["samples"] = latent_x0_tensor
out_xt = latent.copy()
out_xt["samples"] = latent_xt_tensor
# Clean up downscale ratio attributes if present.
out_x0.pop("downscale_ratio_spacial", None)
out_x0.pop("downscale_ratio_temporal", None)
out_xt.pop("downscale_ratio_spacial", None)
out_xt.pop("downscale_ratio_temporal", None)
print(
f"[ComfyUI_TJS] Custom Advanced: "
f"K={total_steps} gamma={gamma:.3f} "
f"k*={k_star} sigma*={sigma_star:.6g} "
f"NFE={nfe_used} saving={nfe_saving:.1f}%"
)
return (out_x0, out_xt, k_star, nfe_used, round(nfe_saving, 1), sigma_star)
# ---------------------------------------------------------------------------
# TJSCustom (SamplerCustom + TJS endpoint decode)
# ---------------------------------------------------------------------------
class TJSCustom:
"""TJS for the standard "custom sampler" paradigm.
Mirrors ComfyUI's built-in ``SamplerCustom`` node, but adds TJS early-exit:
k* = ceil(gamma * (len(sigmas) - 1))
The node runs ``sample_custom`` with sigmas ``[sigma_0, ..., sigma_{k*}, 0]``
— the appended 0 makes the sampler's last step the endpoint decode, and the
callback captures both x0 (denoised) and xt (state at sigma*) in a single
pass. This avoids the overhead of a separate endpoint-decode sampling call.
Inputs are identical to SamplerCustom (model, add_noise, noise_seed, cfg,
positive, negative, sampler, sigmas, latent_image) plus ``early_exit_gamma``.
Workflow::
BasicScheduler → sigmas
KSamplerSelect → sampler
model + add_noise + noise_seed + cfg + positive + negative
+ sampler + sigmas + latent_image + early_exit_gamma
→ TJSCustom
→ latent_x0 (endpoint decoded, feed to VAE Decode)
→ latent_xt (truncated sampling output)
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"add_noise": (
"BOOLEAN",
{"default": True, "tooltip": "Whether to add noise to the latent."},
),
"noise_seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
"control_after_generate": True,
},
),
"cfg": (
"FLOAT",
{"default": 8.0, "min": 0.0, "max": 100.0, "step": 0.1, "round": 0.01},
),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"sampler": ("SAMPLER",),
"sigmas": ("SIGMAS",),
"latent_image": ("LATENT",),
"early_exit_gamma": (
"FLOAT",
{
"default": 0.6,
"min": 0.05,
"max": 1.0,
"step": 0.05,
"display": "slider",
"tooltip": "TJS early-exit ratio. k* = ceil(gamma * steps).",
},
),
},
}
RETURN_TYPES = ("LATENT", "LATENT", "INT", "INT", "FLOAT", "FLOAT")
RETURN_NAMES = (
"latent_x0",
"latent_xt",
"k_star",
"nfe_used",
"nfe_saving_pct",
"sigma_at_exit",
)
FUNCTION = "sample"
CATEGORY = "sampling/TJS"
def sample(
self,
model,
add_noise,
noise_seed,
cfg,
positive,
negative,
sampler,
sigmas,
latent_image,
early_exit_gamma,
):
gamma = float(early_exit_gamma)
latent = latent_image.copy()
latent_samples = comfy.sample.fix_empty_latent_channels(
model, latent["samples"],
)
latent["samples"] = latent_samples
noise_mask = latent.get("noise_mask")
# Generate noise.
if not add_noise:
noise = _zeros_like_noise(latent_samples)
else:
noise = _prepare_noise(latent, noise_seed)
# Handle empty sigmas.
sigmas_list = sigmas.tolist() if hasattr(sigmas, "tolist") else list(sigmas)
if len(sigmas_list) == 0:
out = latent.copy()
print("[ComfyUI_TJS] Custom: empty sigmas, returning input")
return (out, out, 0, 0, 0.0, 0.0)
total_steps = len(sigmas_list) - 1
if total_steps <= 0:
out = latent.copy()
print("[ComfyUI_TJS] Custom: no steps to run")
return (out, out, 0, 0, 0.0, 0.0)
disable_pbar = not _progress_enabled()
# ---- Full-schedule fast path (gamma ≈ 1.0) ----------------------
if gamma >= 0.999999 or total_steps <= 1:
x0_output = {}
callback = latent_preview.prepare_callback(
model, total_steps, x0_output,
)
samples = _sample_custom(
model, noise, cfg, sampler, sigmas,
positive, negative, latent_samples,
noise_mask=noise_mask, callback=callback,
seed=noise_seed,
)
out_xt = latent.copy()
out_xt["samples"] = samples
if "x0" in x0_output:
x0_out = model.model.process_latent_out(
x0_output["x0"].detach().cpu(),
)
out_x0 = latent.copy()
out_x0["samples"] = x0_out
else:
out_x0 = out_xt
out_x0.pop("downscale_ratio_spacial", None)
out_x0.pop("downscale_ratio_temporal", None)
out_xt.pop("downscale_ratio_spacial", None)
out_xt.pop("downscale_ratio_temporal", None)
print(
f"[ComfyUI_TJS] Custom: full-schedule "
f"K={total_steps} NFE={total_steps}"
)
return (out_x0, out_xt, total_steps, total_steps, 0.0, 0.0)
# ---- Compute k* and sigma* --------------------------------------
k_star = max(1, math.ceil(gamma * total_steps))
k_star = min(k_star, total_steps)
# If k* reaches the end of the schedule, use the full-schedule path.
if k_star >= total_steps:
x0_output = {}
callback = latent_preview.prepare_callback(
model, total_steps, x0_output,
)
samples = _sample_custom(
model, noise, cfg, sampler, sigmas,
positive, negative, latent_samples,
noise_mask=noise_mask, callback=callback,
seed=noise_seed,
)
out_xt = latent.copy()
out_xt["samples"] = samples
if "x0" in x0_output:
x0_out = model.model.process_latent_out(
x0_output["x0"].detach().cpu(),
)
out_x0 = latent.copy()
out_x0["samples"] = x0_out
else:
out_x0 = out_xt
out_x0.pop("downscale_ratio_spacial", None)
out_x0.pop("downscale_ratio_temporal", None)
out_xt.pop("downscale_ratio_spacial", None)
out_xt.pop("downscale_ratio_temporal", None)
print(
f"[ComfyUI_TJS] Custom: full-schedule "
f"K={total_steps} NFE={total_steps}"
)
return (out_x0, out_xt, total_steps, total_steps, 0.0, 0.0)
sigma_star = float(sigmas_list[k_star])
# ---- Single-call TJS (truncated + endpoint decode) -------------
# Append 0 to truncated sigmas so the sampler's last step
# (sigma* → 0) is the endpoint decode, captured via callback.
tjs_sigmas = torch.cat([
sigmas[:k_star + 1],
torch.zeros(1, dtype=sigmas.dtype, device=sigmas.device),
])
tjs_steps = k_star + 1
x0_output = {}
xt_output = {}
callback = _make_tjs_callback(
model, tjs_steps, tjs_steps - 1, x0_output, xt_output,
)
samples = _sample_custom(
model, noise, cfg, sampler, tjs_sigmas,
positive, negative, latent_samples,
noise_mask=noise_mask, callback=callback,
seed=noise_seed,
)
latent_x0 = _callback_x0_to_latent(model, x0_output, samples)
if "xt" in xt_output:
latent_xt = model.model.process_latent_out(
xt_output["xt"].detach().cpu(),
)
else:
latent_xt = samples
nfe_used = k_star + 1
nfe_saving = max(0.0, (1.0 - nfe_used / float(total_steps)) * 100.0)
out_x0 = latent.copy()
out_x0["samples"] = latent_x0
out_xt = latent.copy()
out_xt["samples"] = latent_xt
out_x0.pop("downscale_ratio_spacial", None)
out_x0.pop("downscale_ratio_temporal", None)
out_xt.pop("downscale_ratio_spacial", None)
out_xt.pop("downscale_ratio_temporal", None)
print(
f"[ComfyUI_TJS] Custom: "
f"K={total_steps} gamma={gamma:.3f} "
f"k*={k_star} sigma*={sigma_star:.6g} "
f"NFE={nfe_used} saving={nfe_saving:.1f}%"
)
return (out_x0, out_xt, k_star, nfe_used, round(nfe_saving, 1), sigma_star)
# ---------------------------------------------------------------------------
# Node registry
# ---------------------------------------------------------------------------
NODE_CLASS_MAPPINGS = {
"TJSSampler": TJSSampler,
"TJSAdvancedSampler": TJSAdvancedSampler,
"TJSCustom": TJSCustom,
"TJSCustomAdvanced": TJSCustomAdvanced,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TJSSampler": "TJS Sampler (Truncated Jump Sampling)",
"TJSAdvancedSampler": "TJS Advanced Sampler (KSampler Advanced + Endpoint)",
"TJSCustom": "TJS Custom (SamplerCustom + Endpoint)",
"TJSCustomAdvanced": "TJS Custom Advanced (SamplerCustomAdvanced + Endpoint)",
}