diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b31793b --- /dev/null +++ b/__init__.py @@ -0,0 +1,41 @@ +""" +Z-Image-Turbo ComfyUI Nodes - Optimized & Verified +=================================================== +Based on official Tongyi-MAI/Z-Image repository and HuggingFace diffusers implementation. + +CRITICAL FACTS about Z-Image-Turbo: +1. Distilled using Decoupled-DMD on LINEAR sigma schedule (1.0 → 0.0) +2. Optimal: 8-9 steps, CFG=1.0, Euler sampler +3. Any deviation from linear schedule BREAKS the distillation +4. The model expects sigma values as-is - no "recalibration" needed + +WHY CUSTOM SAMPLERS? +ComfyUI's built-in euler uses the Karras ODE formulation with s_churn machinery. +Our samplers use the clean interpolation formula which is: +- More numerically stable (no division by small sigma) +- Cleaner (no s_churn/s_tmin/s_tmax) +- Explicit handling of final step (sigma_next = 0) + +Available samplers (in KSampler dropdown): +- euler_flow: Clean 1st order Euler +- euler_flow_alt: Derivative formulation +- euler_flow_verbose: With debug output +- midpoint_flow: 2nd order midpoint +- heun_flow: 2nd order Heun + +Author: Capitan01R +Version: 1.0.0 +""" + +from .zimage_turbo_core import ( + NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS, +) + +__all__ = [ + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", +] + +__version__ = "1.0.0" +__author__ = "Capitan01R" diff --git a/zimage_turbo_core.py b/zimage_turbo_core.py new file mode 100644 index 0000000..9605df8 --- /dev/null +++ b/zimage_turbo_core.py @@ -0,0 +1,1006 @@ +""" +Z-Image-Turbo Core - Scheduler & Sampler +========================================= +100% aligned with official Tongyi-MAI/Z-Image implementation. + +The official inference code uses: + pipe( + num_inference_steps=9, # Results in 8 DiT forwards + guidance_scale=0.0, # CFG=1.0 (no guidance) + ) + +With linear timesteps from 1.0 to 0.0 and Euler sampling. + +WHY A CUSTOM SAMPLER? +===================== +ComfyUI's built-in `sample_euler` uses the Karras ODE formulation: + d = (x - denoised) / sigma + x = x + d * dt + +This is mathematically equivalent to the RF formulation but: +1. Has s_churn/s_tmin/s_tmax machinery that can add noise +2. No explicit handling of sigma_next == 0 (final step) +3. Division by sigma can cause numerical instability near 0 + +Our RF-specific sampler uses the explicit interpolation formula: + ratio = sigma_next / sigma + x = ratio * x + (1 - ratio) * denoised + +This is: +- Cleaner and more numerically stable +- Exactly what HuggingFace's FlowMatchEulerDiscreteScheduler does +- No extra noise injection mechanisms +""" + +import torch +import math +import comfy.model_management +import comfy.samplers +from comfy.k_diffusion import sampling as k_diffusion_sampling +from comfy.samplers import SchedulerHandler +from tqdm import trange + + +# ============================================================================= +# CORE EULER SAMPLER FOR RECTIFIED FLOW (CLEAN IMPLEMENTATION) +# ============================================================================= + +def sample_euler_flow(model, x, sigmas, extra_args=None, callback=None, disable=None, + eta=0.0, s_noise=1.0, noise_sampler=None): + """ + Clean Euler sampler for Rectified Flow / Flow Matching models. + + Uses the INTERPOLATION formulation which is more numerically stable: + ratio = sigma_next / sigma + x_next = ratio * x + (1 - ratio) * denoised + + Args: + eta: Ancestral noise amount (0.0 = deterministic, 1.0 = full ancestral) + - 0.0: Sharp, deterministic results (default for Z-Image-Turbo) + - 0.1-0.3: Slight variation, can reduce artifacts + - 0.5-1.0: More creative variation, softer results + s_noise: Noise multiplier (default 1.0) + noise_sampler: Custom noise sampler function (optional) + + For Z-Image-Turbo, Flux, SD3, and other flow-matching models. + """ + extra_args = {} if extra_args is None else extra_args + s_in = x.new_ones([x.shape[0]]) + + # Setup noise sampler if using ancestral noise + if eta > 0 and noise_sampler is None: + seed = extra_args.get("seed", None) + if seed is not None: + generator = torch.Generator(device=x.device) + generator.manual_seed(seed + 1) # Offset seed for noise + else: + generator = None + noise_sampler = lambda sigma, sigma_next: torch.randn( + x.size(), dtype=x.dtype, device=x.device, generator=generator + ) + + for i in trange(len(sigmas) - 1, disable=disable): + sigma = sigmas[i] + sigma_next = sigmas[i + 1] + + # Model prediction (denoised image) + denoised = model(x, sigma * s_in, **extra_args) + + # Callback before update + if callback is not None: + callback({ + 'x': x, + 'i': i, + 'sigma': sigma, + 'sigma_hat': sigma, + 'denoised': denoised + }) + + # Euler step with optional ancestral noise + if sigma_next == 0: + # Final step: just return denoised (no noise at the end) + x = denoised + else: + if eta > 0: + # Ancestral sampling: add noise for variation/sharpness control + # Calculate how much to denoise vs how much noise to add back + sigma_down = sigma_next * (1 + (sigma_next / sigma - 1) * eta) + sigma_up = (sigma_next ** 2 - sigma_down ** 2).clamp(min=0).sqrt() + + # Step to sigma_down + ratio = sigma_down / sigma + x = ratio * x + (1.0 - ratio) * denoised + + # Add noise back + if sigma_up > 0: + noise = noise_sampler(sigma, sigma_next) + x = x + noise * sigma_up * s_noise + else: + # Deterministic: pure interpolation + ratio = sigma_next / sigma + x = ratio * x + (1.0 - ratio) * denoised + + return x + + +def sample_euler_flow_alt(model, x, sigmas, extra_args=None, callback=None, disable=None, + eta=0.0, s_noise=1.0, noise_sampler=None): + """ + Alternative Euler using the derivative formulation. + + For comparison and testing. Uses: + d = (x - denoised) / sigma + x_next = x + d * dt + + Handles sigma ≈ 0 explicitly. + """ + extra_args = {} if extra_args is None else extra_args + s_in = x.new_ones([x.shape[0]]) + + # Setup noise sampler + if eta > 0 and noise_sampler is None: + seed = extra_args.get("seed", None) + if seed is not None: + generator = torch.Generator(device=x.device) + generator.manual_seed(seed + 1) + else: + generator = None + noise_sampler = lambda sigma, sigma_next: torch.randn( + x.size(), dtype=x.dtype, device=x.device, generator=generator + ) + + for i in trange(len(sigmas) - 1, disable=disable): + sigma = sigmas[i] + sigma_next = sigmas[i + 1] + + denoised = model(x, sigma * s_in, **extra_args) + + if callback is not None: + callback({ + 'x': x, + 'i': i, + 'sigma': sigma, + 'sigma_hat': sigma, + 'denoised': denoised + }) + + if sigma_next == 0: + x = denoised + elif sigma > 1e-5: + if eta > 0: + sigma_down = sigma_next * (1 + (sigma_next / sigma - 1) * eta) + sigma_up = (sigma_next ** 2 - sigma_down ** 2).clamp(min=0).sqrt() + + d = (x - denoised) / sigma + dt = sigma_down - sigma + x = x + d * dt + + if sigma_up > 0: + x = x + noise_sampler(sigma, sigma_next) * sigma_up * s_noise + else: + d = (x - denoised) / sigma + dt = sigma_next - sigma + x = x + d * dt + else: + x = denoised + + return x + + +def sample_midpoint_flow(model, x, sigmas, extra_args=None, callback=None, disable=None, + eta=0.0, s_noise=1.0, noise_sampler=None): + """ + Midpoint method (2nd order) for Rectified Flow. + + Uses two model evaluations per step for better accuracy: + 1. Evaluate at sigma_i to get velocity estimate + 2. Step to midpoint + 3. Evaluate at midpoint for better velocity + 4. Use midpoint velocity for full step + + More accurate than Euler, costs 2x NFE. + """ + extra_args = {} if extra_args is None else extra_args + s_in = x.new_ones([x.shape[0]]) + + # Setup noise sampler + if eta > 0 and noise_sampler is None: + seed = extra_args.get("seed", None) + if seed is not None: + generator = torch.Generator(device=x.device) + generator.manual_seed(seed + 1) + else: + generator = None + noise_sampler = lambda sigma, sigma_next: torch.randn( + x.size(), dtype=x.dtype, device=x.device, generator=generator + ) + + for i in trange(len(sigmas) - 1, disable=disable): + sigma = sigmas[i] + sigma_next = sigmas[i + 1] + + if callback is not None: + callback({ + 'x': x, + 'i': i, + 'sigma': sigma, + 'sigma_hat': sigma, + 'denoised': None + }) + + if sigma_next == 0: + x = model(x, sigma * s_in, **extra_args) + else: + sigma_mid = (sigma + sigma_next) / 2 + + # First evaluation at current sigma + denoised = model(x, sigma * s_in, **extra_args) + + # Step to midpoint + ratio_mid = sigma_mid / sigma + x_mid = ratio_mid * x + (1.0 - ratio_mid) * denoised + + # Second evaluation at midpoint + denoised_mid = model(x_mid, sigma_mid * s_in, **extra_args) + + # Calculate target sigma (with eta for ancestral) + if eta > 0: + sigma_down = sigma_next * (1 + (sigma_next / sigma - 1) * eta) + sigma_up = (sigma_next ** 2 - sigma_down ** 2).clamp(min=0).sqrt() + else: + sigma_down = sigma_next + sigma_up = 0 + + # Full step using midpoint velocity + ratio = sigma_down / sigma + x = ratio * x + (1.0 - ratio) * denoised_mid + + # Add ancestral noise + if eta > 0 and sigma_up > 0: + x = x + noise_sampler(sigma, sigma_next) * sigma_up * s_noise + + return x + + +def sample_heun_flow(model, x, sigmas, extra_args=None, callback=None, disable=None, + eta=0.0, s_noise=1.0, noise_sampler=None): + """ + Heun's method (2nd order) for Rectified Flow. + + Uses trapezoidal rule - average of velocities at start and end: + 1. Evaluate at sigma_i + 2. Predict x at sigma_{i+1} using Euler + 3. Evaluate at predicted point + 4. Average both velocities for final step + + More accurate than Euler, costs 2x NFE. + """ + extra_args = {} if extra_args is None else extra_args + s_in = x.new_ones([x.shape[0]]) + + # Setup noise sampler + if eta > 0 and noise_sampler is None: + seed = extra_args.get("seed", None) + if seed is not None: + generator = torch.Generator(device=x.device) + generator.manual_seed(seed + 1) + else: + generator = None + noise_sampler = lambda sigma, sigma_next: torch.randn( + x.size(), dtype=x.dtype, device=x.device, generator=generator + ) + + for i in trange(len(sigmas) - 1, disable=disable): + sigma = sigmas[i] + sigma_next = sigmas[i + 1] + + # First evaluation + denoised = model(x, sigma * s_in, **extra_args) + + if callback is not None: + callback({ + 'x': x, + 'i': i, + 'sigma': sigma, + 'sigma_hat': sigma, + 'denoised': denoised + }) + + if sigma_next == 0: + x = denoised + else: + # Calculate target sigma (with eta for ancestral) + if eta > 0: + sigma_down = sigma_next * (1 + (sigma_next / sigma - 1) * eta) + sigma_up = (sigma_next ** 2 - sigma_down ** 2).clamp(min=0).sqrt() + else: + sigma_down = sigma_next + sigma_up = 0 + + # Euler prediction to sigma_down + ratio = sigma_down / sigma + x_pred = ratio * x + (1.0 - ratio) * denoised + + # Second evaluation at predicted point + denoised_2 = model(x_pred, sigma_down * s_in, **extra_args) + + # Heun's method: average the two denoised predictions + denoised_avg = (denoised + denoised_2) / 2 + + # Final step + x = ratio * x + (1.0 - ratio) * denoised_avg + + # Add ancestral noise + if eta > 0 and sigma_up > 0: + x = x + noise_sampler(sigma, sigma_next) * sigma_up * s_noise + + return x + + +def sample_euler_flow_verbose(model, x, sigmas, extra_args=None, callback=None, disable=None, + eta=0.0, s_noise=1.0, noise_sampler=None): + """ + Verbose version with debug output. Use for troubleshooting. + """ + extra_args = {} if extra_args is None else extra_args + s_in = x.new_ones([x.shape[0]]) + + # Setup noise sampler + if eta > 0 and noise_sampler is None: + seed = extra_args.get("seed", None) + if seed is not None: + generator = torch.Generator(device=x.device) + generator.manual_seed(seed + 1) + else: + generator = None + noise_sampler = lambda sigma, sigma_next: torch.randn( + x.size(), dtype=x.dtype, device=x.device, generator=generator + ) + + print(f"\n{'='*60}") + print(f"[Z-Image Flow Euler] Starting sampling") + print(f"{'='*60}") + print(f" Latent shape: {x.shape}") + print(f" Steps: {len(sigmas) - 1}") + print(f" eta: {eta}, s_noise: {s_noise}") + print(f" Initial x: mean={x.mean().item():.4f}, std={x.std().item():.4f}") + print(f"\n Sigma schedule:") + for idx, s in enumerate(sigmas): + print(f" [{idx}] σ = {s.item():.6f}") + print() + + for i in range(len(sigmas) - 1): + sigma = sigmas[i] + sigma_next = sigmas[i + 1] + + denoised = model(x, sigma * s_in, **extra_args) + + if callback is not None: + callback({ + 'x': x, + 'i': i, + 'sigma': sigma, + 'sigma_hat': sigma, + 'denoised': denoised + }) + + x_before = x.clone() + + if sigma_next == 0: + x = denoised + method = "final (x=denoised)" + else: + if eta > 0: + sigma_down = sigma_next * (1 + (sigma_next / sigma - 1) * eta) + sigma_up = (sigma_next ** 2 - sigma_down ** 2).clamp(min=0).sqrt() + + ratio = sigma_down / sigma + x = ratio * x + (1.0 - ratio) * denoised + + if sigma_up > 0: + noise = noise_sampler(sigma, sigma_next) + x = x + noise * sigma_up * s_noise + + method = f"ancestral (η={eta}, σ_down={sigma_down.item():.4f}, σ_up={sigma_up.item():.4f})" + else: + ratio = sigma_next / sigma + x = ratio * x + (1.0 - ratio) * denoised + method = f"deterministic (ratio={ratio.item():.4f})" + + print(f" Step {i+1}/{len(sigmas)-1}: σ={sigma.item():.4f} → {sigma_next.item():.4f}") + print(f" Method: {method}") + print(f" denoised: mean={denoised.mean().item():.4f}, std={denoised.std().item():.4f}") + print(f" x_new: mean={x.mean().item():.4f}, std={x.std().item():.4f}") + print(f" Δx: mean={(x - x_before).mean().item():.4f}, std={(x - x_before).std().item():.4f}") + + print(f"\n{'='*60}") + print(f" Final: mean={x.mean().item():.4f}, std={x.std().item():.4f}") + print(f"{'='*60}\n") + return x + + +# ============================================================================= +# SIGMA SCHEDULE GENERATORS +# ============================================================================= + +def get_sigmas_linear(n: int, sigma_max: float = 1.0, sigma_min: float = 0.0, + device='cpu') -> torch.Tensor: + """ + Linear sigma schedule for Z-Image-Turbo. + + This is THE correct schedule - the model was distilled on this exact progression. + + Args: + n: Number of steps (9 recommended) + sigma_max: Starting sigma (1.0) + sigma_min: Ending sigma (0.0) + + Returns: + Tensor of shape (n+1,) from sigma_max to sigma_min + Example (n=8): [1.000, 0.875, 0.750, 0.625, 0.500, 0.375, 0.250, 0.125, 0.000] + """ + return torch.linspace(sigma_max, sigma_min, n + 1, device=device) + + +def get_sigmas_shifted(n: int, shift: float = 1.0, device='cpu') -> torch.Tensor: + """ + Flow-matching schedule with shift (SD3/Flux style). + + NOTE: Z-Image-Turbo uses shift=1.0 (no shift). This is here for + compatibility with other flow-matching models like Flux or SD3. + + Formula: σ_shifted = shift * σ / (1 + (shift - 1) * σ) + + Args: + n: Number of steps + shift: Shift factor (1.0=no shift, 3.0=SD3 default) + + Properties: + - shift=1.0: Linear schedule (Z-Image-Turbo) + - shift>1.0: More time at high noise (better composition) + - σ=1.0 always maps to 1.0, σ=0.0 always maps to 0.0 + """ + timesteps = torch.linspace(1.0, 0.0, n + 1, device=device) + + if shift == 1.0: + return timesteps + + sigmas = shift * timesteps / (1.0 + (shift - 1.0) * timesteps) + + # Ensure exact endpoints + sigmas[0] = 1.0 + sigmas[-1] = 0.0 + + return sigmas + + +# ============================================================================= +# REGISTER SAMPLERS IN COMFYUI +# ============================================================================= + +def _register_samplers(): + """Register custom samplers with ComfyUI's k-diffusion.""" + samplers = [ + ("euler_flow", sample_euler_flow), + ("euler_flow_alt", sample_euler_flow_alt), + ("euler_flow_verbose", sample_euler_flow_verbose), + ("midpoint_flow", sample_midpoint_flow), + ("heun_flow", sample_heun_flow), + ] + + for name, func in samplers: + if name not in comfy.samplers.KSampler.SAMPLERS: + comfy.samplers.KSampler.SAMPLERS.append(name) + setattr(k_diffusion_sampling, f'sample_{name}', func) + + print(f"[Z-Image-Turbo] Registered samplers: {[s[0] for s in samplers]}") + +_register_samplers() + + +# ============================================================================= +# REGISTER SCHEDULER IN COMFYUI +# ============================================================================= + +def _zimage_turbo_scheduler(model, steps): + """Default scheduler for Z-Image-Turbo - pure linear.""" + device = comfy.model_management.get_torch_device() + return get_sigmas_linear(steps, 1.0, 0.0, device) + +try: + comfy.samplers.SCHEDULER_HANDLERS["zimage_turbo"] = SchedulerHandler( + _zimage_turbo_scheduler, use_ms=True + ) + if "zimage_turbo" not in comfy.samplers.SCHEDULER_NAMES: + comfy.samplers.SCHEDULER_NAMES.append("zimage_turbo") +except Exception as e: + print(f"[Z-Image-Turbo] Warning: Could not register scheduler: {e}") + + +# ============================================================================= +# COMFYUI NODES +# ============================================================================= + +class ZImageTurboScheduler: + """ + Sigma scheduler optimized for Z-Image-Turbo. + + Produces the exact linear schedule the model was trained on. + Use with Euler sampler at CFG=1.0. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "steps": ("INT", { + "default": 9, + "min": 1, + "max": 50, + "step": 1, + "tooltip": "8-9 steps optimal for Z-Image-Turbo" + }), + } + } + + RETURN_TYPES = ("SIGMAS",) + FUNCTION = "generate" + CATEGORY = "sampling/custom_sampling/schedulers" + DESCRIPTION = """Linear sigma schedule for Z-Image-Turbo. + +IMPORTANT: Z-Image-Turbo was distilled on this exact linear schedule. +Using non-linear schedules will produce worse results. + +Recommended settings: +• Steps: 8-9 +• Sampler: euler or euler_rf_zimage +• CFG: 1.0""" + + def generate(self, steps): + device = comfy.model_management.get_torch_device() + sigmas = get_sigmas_linear(steps, 1.0, 0.0, device) + return (sigmas,) + + +class ZImageTurboSchedulerAdvanced: + """ + Advanced scheduler with denoise control for img2img. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "steps": ("INT", { + "default": 9, + "min": 1, + "max": 50, + "step": 1, + }), + "denoise": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": "1.0=full generation, <1.0=img2img strength" + }), + } + } + + RETURN_TYPES = ("SIGMAS",) + FUNCTION = "generate" + CATEGORY = "sampling/custom_sampling/schedulers" + DESCRIPTION = """Linear scheduler with denoise control for img2img. + +denoise=1.0: Full generation (txt2img) +denoise=0.5: 50% noise (img2img)""" + + def generate(self, steps, denoise): + device = comfy.model_management.get_torch_device() + # Start from denoise level instead of 1.0 + sigmas = get_sigmas_linear(steps, denoise, 0.0, device) + return (sigmas,) + + +class FlowMatchScheduler: + """ + Generic flow-matching scheduler with shift support. + For Flux, SD3, and other flow-matching models. + + NOTE: For Z-Image-Turbo, use shift=1.0 (or use ZImageTurboScheduler instead). + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "steps": ("INT", { + "default": 20, + "min": 1, + "max": 1000, + "step": 1, + }), + "shift": ("FLOAT", { + "default": 1.0, + "min": 0.1, + "max": 10.0, + "step": 0.1, + "tooltip": "1.0=Z-Image, 3.0=SD3, 2-6=Flux (resolution dependent)" + }), + } + } + + RETURN_TYPES = ("SIGMAS",) + FUNCTION = "generate" + CATEGORY = "sampling/custom_sampling/schedulers" + DESCRIPTION = """Flow-matching scheduler with shift parameter. + +Shift values: +• 1.0 = Linear (Z-Image-Turbo) +• 3.0 = SD3 default +• 2.0-6.0 = Flux (varies by resolution) + +Higher shift = more time at high noise = better composition.""" + + def generate(self, steps, shift): + device = comfy.model_management.get_torch_device() + sigmas = get_sigmas_shifted(steps, shift, device) + return (sigmas,) + + +class FlowMatchSchedulerDynamic: + """ + Resolution-aware scheduler (Flux style). + Auto-computes shift based on image dimensions. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "steps": ("INT", {"default": 20, "min": 1, "max": 1000, "step": 1}), + "width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8}), + "height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8}), + "base_shift": ("FLOAT", { + "default": 0.5, + "min": 0.0, + "max": 5.0, + "step": 0.05, + "tooltip": "mu for small images (256x256)" + }), + "max_shift": ("FLOAT", { + "default": 1.15, + "min": 0.0, + "max": 5.0, + "step": 0.05, + "tooltip": "mu for large images (512x512+)" + }), + } + } + + RETURN_TYPES = ("SIGMAS", "FLOAT") + RETURN_NAMES = ("sigmas", "computed_shift") + FUNCTION = "generate" + CATEGORY = "sampling/custom_sampling/schedulers" + DESCRIPTION = """Resolution-aware scheduler (Flux style). + +Automatically computes shift based on image size. +Not needed for Z-Image-Turbo (use ZImageTurboScheduler instead).""" + + def generate(self, steps, width, height, base_shift, max_shift): + device = comfy.model_management.get_torch_device() + + # Compute image sequence length (VAE latent tokens) + image_seq_len = (height // 8) * (width // 8) + + # Linear interpolation for mu + base_seq_len = 256 + max_seq_len = 4096 + image_seq_len = max(base_seq_len, min(max_seq_len, image_seq_len)) + + m = (max_shift - base_shift) / (max_seq_len - base_seq_len) + mu = base_shift + m * (image_seq_len - base_seq_len) + + # Convert mu to shift via exponential + computed_shift = math.exp(mu) + + # Generate schedule + sigmas = get_sigmas_shifted(steps, computed_shift, device) + + return (sigmas, computed_shift) + + +class ZImageSampler: + """ + Clean Euler sampler for Rectified Flow / Flow Matching. + + Uses the interpolation formula which is more numerically stable + than the Karras ODE formulation used in ComfyUI's default euler. + """ + + SAMPLER_TYPES = ["euler", "euler_alt", "midpoint", "heun"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "sampler_type": (cls.SAMPLER_TYPES, { + "default": "euler", + "tooltip": "euler=fast, midpoint/heun=2nd order (2x slower but more accurate)" + }), + "eta": ("FLOAT", { + "default": 0.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "tooltip": "Ancestral noise. 0=deterministic/sharp, 0.1-0.3=slight variation, 0.5-1.0=creative/soft" + }), + "s_noise": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 2.0, + "step": 0.05, + "tooltip": "Noise multiplier when eta>0. Higher=more variation" + }), + "verbose": ("BOOLEAN", { + "default": False, + "tooltip": "Print debug info during sampling" + }), + } + } + + RETURN_TYPES = ("SAMPLER",) + FUNCTION = "get_sampler" + CATEGORY = "sampling/custom_sampling/samplers" + DESCRIPTION = """Clean Euler sampler for Rectified Flow / Flow Matching. + +Sampler types: +• euler: Standard 1st order (fast, recommended) +• euler_alt: Alternative derivative formulation +• midpoint: 2nd order midpoint method (2x NFE) +• heun: 2nd order Heun/trapezoidal (2x NFE) + +eta (ancestral noise): +• 0.0: Deterministic, sharpest results (default) +• 0.1-0.2: Slight variation, can reduce artifacts +• 0.3-0.5: More variation, slightly softer +• 0.5-1.0: Creative variation, softest + +s_noise: Multiplier for ancestral noise (when eta > 0) + +Works with Z-Image-Turbo, Flux, SD3, and other flow-matching models.""" + + def get_sampler(self, sampler_type, eta, s_noise, verbose): + + class FlowSampler: + def __init__(self, stype, eta, s_noise, verbose): + self.sampler_type = stype + self.eta = eta + self.s_noise = s_noise + self.verbose = verbose + + # Match KSAMPLER.sample() signature exactly: + # sample(self, model_wrap, sigmas, extra_args, callback, noise, latent_image=None, denoise_mask=None, disable_pbar=False) + def sample(self, model_wrap, sigmas, extra_args, callback, noise, latent_image=None, denoise_mask=None, disable_pbar=False): + # Setup like KSAMPLER does + from comfy.samplers import KSamplerX0Inpaint + + extra_args["denoise_mask"] = denoise_mask + model_k = KSamplerX0Inpaint(model_wrap, sigmas) + model_k.latent_image = latent_image + model_k.noise = noise + + # Scale noise for first step + x = model_wrap.inner_model.model_sampling.noise_scaling( + sigmas[0], noise, latent_image, + self.max_denoise(model_wrap, sigmas) + ) + + # Setup callback + k_callback = None + total_steps = len(sigmas) - 1 + if callback is not None: + k_callback = lambda cb_data: callback(cb_data["i"], cb_data["denoised"], cb_data["x"], total_steps) + + # Call the appropriate sampler function + if self.sampler_type == "euler": + if self.verbose: + samples = sample_euler_flow_verbose( + model_k, x, sigmas, extra_args, k_callback, disable_pbar, + eta=self.eta, s_noise=self.s_noise + ) + else: + samples = sample_euler_flow( + model_k, x, sigmas, extra_args, k_callback, disable_pbar, + eta=self.eta, s_noise=self.s_noise + ) + elif self.sampler_type == "euler_alt": + samples = sample_euler_flow_alt( + model_k, x, sigmas, extra_args, k_callback, disable_pbar, + eta=self.eta, s_noise=self.s_noise + ) + elif self.sampler_type == "midpoint": + samples = sample_midpoint_flow( + model_k, x, sigmas, extra_args, k_callback, disable_pbar, + eta=self.eta, s_noise=self.s_noise + ) + elif self.sampler_type == "heun": + samples = sample_heun_flow( + model_k, x, sigmas, extra_args, k_callback, disable_pbar, + eta=self.eta, s_noise=self.s_noise + ) + else: + samples = sample_euler_flow( + model_k, x, sigmas, extra_args, k_callback, disable_pbar, + eta=self.eta, s_noise=self.s_noise + ) + + # Inverse noise scaling + samples = model_wrap.inner_model.model_sampling.inverse_noise_scaling(sigmas[-1], samples) + return samples + + def max_denoise(self, model_wrap, sigmas): + max_sigma = float(model_wrap.inner_model.model_sampling.sigma_max) + sigma = float(sigmas[0]) + return math.isclose(max_sigma, sigma, rel_tol=1e-05) or sigma > max_sigma + + return (FlowSampler(sampler_type, eta, s_noise, verbose),) + + +class SigmaViewer: + """ + Debug node to visualize sigma schedules. + Pass-through: doesn't modify sigmas. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "sigmas": ("SIGMAS",), + } + } + + RETURN_TYPES = ("SIGMAS", "STRING") + RETURN_NAMES = ("sigmas", "info") + FUNCTION = "view" + CATEGORY = "sampling/custom_sampling/schedulers" + DESCRIPTION = "Visualize sigma schedule values. Pass-through node." + + def view(self, sigmas): + n = len(sigmas) - 1 + + lines = [ + f"Sigma Schedule ({n} steps, {len(sigmas)} values)", + "=" * 50, + f"Start (max noise): {sigmas[0].item():.6f}", + f"End (clean): {sigmas[-1].item():.6f}", + "", + "Step-by-step:" + ] + + for i in range(len(sigmas)): + val = sigmas[i].item() + if i > 0: + delta = sigmas[i-1].item() - val + lines.append(f" [{i:2d}] σ = {val:.5f} (Δ = {delta:+.5f})") + else: + lines.append(f" [{i:2d}] σ = {val:.5f}") + + # Check if linear + is_linear = True + if n > 1: + expected_delta = (sigmas[0].item() - sigmas[-1].item()) / n + for i in range(1, len(sigmas)): + actual_delta = sigmas[i-1].item() - sigmas[i].item() + if abs(actual_delta - expected_delta) > 0.001: + is_linear = False + break + + lines.append("") + lines.append(f"Schedule type: {'LINEAR ✓' if is_linear else 'NON-LINEAR'}") + + if not is_linear: + lines.append("⚠️ Warning: Z-Image-Turbo requires LINEAR schedule!") + + info = "\n".join(lines) + print(info) # Also print to console + + return (sigmas, info) + + +class ZImagePresets: + """ + One-click presets for common model configurations. + """ + + PRESETS = [ + "Z-Image-Turbo (8 steps)", + "Z-Image-Turbo (9 steps)", + "Flux Dev (20 steps)", + "Flux Schnell (4 steps)", + "SD3 (28 steps)", + "SD3.5 Turbo (4 steps)", + ] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "preset": (cls.PRESETS,), + }, + "optional": { + "width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8}), + "height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8}), + } + } + + RETURN_TYPES = ("SIGMAS", "INT", "FLOAT") + RETURN_NAMES = ("sigmas", "steps", "recommended_cfg") + FUNCTION = "generate" + CATEGORY = "sampling/custom_sampling/schedulers" + DESCRIPTION = """One-click presets for common models. + +Returns sigmas, optimal step count, and recommended CFG. + +Z-Image-Turbo: Linear schedule, CFG=1.0 +Flux: Dynamic shift based on resolution +SD3: Fixed shift=3.0""" + + def generate(self, preset, width=1024, height=1024): + device = comfy.model_management.get_torch_device() + + configs = { + "Z-Image-Turbo (8 steps)": {"steps": 8, "shift": 1.0, "cfg": 1.0, "dynamic": False}, + "Z-Image-Turbo (9 steps)": {"steps": 9, "shift": 1.0, "cfg": 1.0, "dynamic": False}, + "Flux Dev (20 steps)": {"steps": 20, "shift": None, "cfg": 3.5, "dynamic": True}, + "Flux Schnell (4 steps)": {"steps": 4, "shift": None, "cfg": 1.0, "dynamic": True}, + "SD3 (28 steps)": {"steps": 28, "shift": 3.0, "cfg": 4.5, "dynamic": False}, + "SD3.5 Turbo (4 steps)": {"steps": 4, "shift": 1.0, "cfg": 1.0, "dynamic": False}, + } + + cfg = configs[preset] + steps = cfg["steps"] + recommended_cfg = cfg["cfg"] + + if cfg["dynamic"]: + # Flux-style dynamic shift + image_seq_len = (height // 8) * (width // 8) + base_seq_len, max_seq_len = 256, 4096 + base_shift, max_shift = 0.5, 1.15 + + image_seq_len = max(base_seq_len, min(max_seq_len, image_seq_len)) + m = (max_shift - base_shift) / (max_seq_len - base_seq_len) + mu = base_shift + m * (image_seq_len - base_seq_len) + shift = math.exp(mu) + else: + shift = cfg["shift"] + + sigmas = get_sigmas_shifted(steps, shift, device) + + return (sigmas, steps, recommended_cfg) + + +# ============================================================================= +# NODE REGISTRATION +# ============================================================================= + +NODE_CLASS_MAPPINGS = { + "ZImageTurboScheduler": ZImageTurboScheduler, + "ZImageTurboSchedulerAdvanced": ZImageTurboSchedulerAdvanced, + "FlowMatchScheduler": FlowMatchScheduler, + "FlowMatchSchedulerDynamic": FlowMatchSchedulerDynamic, + "ZImageSampler": ZImageSampler, + "SigmaViewer": SigmaViewer, + "ZImagePresets": ZImagePresets, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ZImageTurboScheduler": "⚡ Z-Image-Turbo Scheduler", + "ZImageTurboSchedulerAdvanced": "⚡ Z-Image-Turbo Scheduler (Advanced)", + "FlowMatchScheduler": "🌊 Flow Match Scheduler", + "FlowMatchSchedulerDynamic": "🌊 Flow Match Scheduler (Dynamic)", + "ZImageSampler": "⚡ Z-Image Flow Sampler", + "SigmaViewer": "🔍 Sigma Viewer", + "ZImagePresets": "🎯 Flow Match Presets", +} \ No newline at end of file