From 49e7517389c85db7ea392dc3960280aa6ff2b99c Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:07:39 +0300 Subject: [PATCH] Add FunPack Normalizing Sampler (video-only latent normalization) New SAMPLER for distilled LTXAV at CFG=1: deterministic 1st-order euler base (clean ddim look + clean audio) plus per-step video-only latent normalization to counter overbaking / oversaturation / colour drift at ~zero overhead. Tracks the video latent's robust spread, anchors a reference once x0 is meaningful (normalize_start_sigma), and compresses inflated later steps (normalize_strength). Benefit-only, no-op when healthy, audio never touched (whole-latent for single-stream LTXV). Velocity bias + rescue included, video-only. Node FunPackNormalizingSampler + Studio sampler option ("Normalizing") wired through _build_one_sampler and the JS panel. Registered in __init__. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 + __init__.py | 7 +- conditioning.py | 23 +++++ samplers.py | 208 ++++++++++++++++++++++++++++++++++++++++++ web/funpack_studio.js | 48 +++++++++- 5 files changed, 283 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 680aebe1..67d468e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +**FunPack Normalizing Sampler** — a new `SAMPLER` (selectable in Studio alongside Hybrid Euler 2S / Distilled Flow / KSampler) built for distilled few-step LTXAV at CFG=1, where the model is already extracted and there's no redundant CFG computation to exploit. It runs a clean deterministic 1st-order euler base (the "ddim look" + clean audio — no AB2/Heun/ancestral) and adds **video-only latent normalization**: each step it tracks the video latent's robust spread, anchors a reference once x0 is meaningful (`normalize_start_sigma`), and gently compresses any later step whose spread inflates past it (`normalize_strength`), countering the overbaking / oversaturation / colour-drift that distilled latents fall into. Benefit-only (never amplifies, no-op when the latent is healthy), ~zero overhead (a few masked reductions), and audio-safe by construction — the audio stream rides plain euler and the normalization never touches it (whole-latent for single-stream LTXV). Velocity bias + reactive rescue are available too, video-only. Node `FunPackNormalizingSampler` + Studio sampler panel. + **Distilled Flow `AB2 ramp` — graduated 2nd order.** New opt-in toggle (off by default) that, instead of applying full Adams-Bashforth 2-step extrapolation on every step, ramps the AB2 contribution linearly from 0→1 across the schedule: early/high-sigma steps stay near plain 1st-order euler (where the denoised estimate is rough and full AB2 tends to overshoot), late/detail steps get full AB2. Reuses the already-computed AB2 estimate, so **no extra model evals** — aimed at squeezing better quality out of low-step distilled runs. No effect at `order=1`. Exposed on the Distilled Flow sampler node and in Studio's sampler panel; audio stays on the plain euler path as always. ### Fixed diff --git a/__init__.py b/__init__.py index f1c82c96..afdfa27d 100644 --- a/__init__.py +++ b/__init__.py @@ -20,7 +20,7 @@ if __package__: FunPackVideoStitch, ) from .model_management import FunPackApplyLoraWeights, FunPackLoraLoader - from .samplers import FunPackHybridEuler2SSampler, FunPackDistilledFlowSampler, FunPackLTXAVSceneChainSampler + from .samplers import FunPackHybridEuler2SSampler, FunPackDistilledFlowSampler, FunPackNormalizingSampler, FunPackLTXAVSceneChainSampler from .templates import FunPackRefinementKeyLoader, FunPackSceneBuilder try: from . import batch_training # noqa: F401 registers /funpack/batch/* routes @@ -59,10 +59,11 @@ else: FunPackApplyLoraWeights = None FunPackLoraLoader = None try: - from samplers import FunPackHybridEuler2SSampler, FunPackDistilledFlowSampler, FunPackLTXAVSceneChainSampler + from samplers import FunPackHybridEuler2SSampler, FunPackDistilledFlowSampler, FunPackNormalizingSampler, FunPackLTXAVSceneChainSampler except Exception: FunPackHybridEuler2SSampler = None FunPackDistilledFlowSampler = None + FunPackNormalizingSampler = None FunPackLTXAVSceneChainSampler = None try: from templates import FunPackRefinementKeyLoader, FunPackSceneBuilder @@ -89,6 +90,7 @@ NODE_CLASS_MAPPINGS = { "FunPackSaveRefinementLatent": FunPackSaveRefinementLatent, "FunPackHybridEuler2SSampler": FunPackHybridEuler2SSampler, "FunPackDistilledFlowSampler": FunPackDistilledFlowSampler, + "FunPackNormalizingSampler": FunPackNormalizingSampler, "FunPackLTXAVSceneChainSampler": FunPackLTXAVSceneChainSampler, "FunPackApplyLoraWeights": FunPackApplyLoraWeights, "FunPackLoraLoader": FunPackLoraLoader, @@ -114,6 +116,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FunPackSaveRefinementLatent": "FunPack Save Refinement Latent", "FunPackHybridEuler2SSampler": "FunPack Hybrid Euler 2S Sampler", "FunPackDistilledFlowSampler": "FunPack Distilled Flow Sampler", + "FunPackNormalizingSampler": "FunPack Normalizing Sampler", "FunPackLTXAVSceneChainSampler": "FunPack LTXAV Scene Chain Sampler", "FunPackApplyLoraWeights": "FunPack Apply LoRA Weights", "FunPackLoraLoader": "FunPack LoRA Loader", diff --git a/conditioning.py b/conditioning.py index 995414ec..b186dd41 100644 --- a/conditioning.py +++ b/conditioning.py @@ -13927,6 +13927,29 @@ class FunPackStudio: rescue_prompt_sig=prompt_sig, sigmas=sigmas_raw, ) + elif sampler_type == "Normalizing": + try: + from .samplers import FunPackNormalizingSampler + except ImportError: + from samplers import FunPackNormalizingSampler + nc = cfg.get("normalizing", {}) if isinstance(cfg.get("normalizing"), dict) else {} + nvkey = str(nc.get("velocity_refinement_key", "") or "").strip() + if not nvkey or nvkey == "default": + nvkey = str(refinement_key or "").strip() or "default" + node = FunPackNormalizingSampler() + sampler, out_sigmas = node.get_sampler( + normalize_strength=float(nc.get("normalize_strength", 0.5)), + normalize_start_sigma=float(nc.get("normalize_start_sigma", 0.9)), + velocity_bias_mode=str(nc.get("velocity_bias_mode", "off")), + velocity_bias_strength=float(nc.get("velocity_bias_strength", 0.0)), + velocity_bias_source=str(nc.get("velocity_bias_source", "mean")), + velocity_refinement_key=nvkey, + rescue_mode=bool(nc.get("rescue_mode", False)), + rescue_threshold=float(nc.get("rescue_threshold", 0.15)), + rescue_strength=float(nc.get("rescue_strength", 0.2)), + rescue_prompt_sig=prompt_sig, + sigmas=sigmas_raw, + ) elif sampler_type == "KSampler": import comfy.samplers as _cs sampler_name = str(cfg.get("ksampler_name", "euler") or "euler") diff --git a/samplers.py b/samplers.py index 72863694..8d386c00 100644 --- a/samplers.py +++ b/samplers.py @@ -1676,6 +1676,214 @@ class FunPackDistilledFlowSampler: return (sampler, prepared_sigmas) +def _normalize_video_denoised(denoised, video_mask, sigma, ref, strength, + start_sigma, tolerance=0.05, beta=0.9): + """Keep the VIDEO latent's spread from drifting into overbaked / oversaturated ranges. + + Operates on the denoised (x0) prediction, video stream only — audio is never touched, so it + is audio-safe by construction. Once x0 is meaningful (sigma <= start_sigma) it anchors a + reference spread (robust std over the video region) and gently compresses any later step + whose spread inflates past it; an EMA lets the reference follow legitimate detail growth + while resisting abrupt overbake spikes. Benefit-only: it never amplifies and is a no-op when + the latent stays healthy. Cost is a few masked reductions per step (~zero overhead). + + `ref` is a 1-element list holding the reference scale across steps (mutable state). + """ + if strength <= 0.0: + return denoised + try: + s = float(sigma.item()) if hasattr(sigma, "item") else float(sigma) + except Exception: + s = 1.0 + if s > start_sigma: + return denoised # skip the pure-noise plateau where the x0 estimate is not yet meaningful + try: + # Stats over the video region (whole tensor for single-stream LTXV, where mask is None). + if video_mask is not None: + m = video_mask + w = m.sum().clamp_min(1.0) + mean = (denoised * m).sum() / w + var = (((denoised - mean) ** 2) * m).sum() / w + else: + mean = denoised.mean() + var = ((denoised - mean) ** 2).mean() + std_v = float(torch.sqrt(var.clamp_min(1e-12)).item()) + if ref[0] is None: + ref[0] = std_v + return denoised + target = ref[0] * (1.0 + tolerance) + if std_v > target and std_v > 1e-6: + factor = 1.0 - strength * (1.0 - target / std_v) # = lerp(1, target/std, strength) + normalized = mean + (denoised - mean) * factor + denoised = _video_only(normalized, denoised, m) + ref[0] = ref[0] * beta + target * (1.0 - beta) + else: + ref[0] = ref[0] * beta + std_v * (1.0 - beta) + except Exception: + return denoised + return denoised + + +def sample_funpack_normalizing(model, x, sigmas, extra_args=None, callback=None, + disable=None, + normalize_strength=0.5, normalize_start_sigma=0.9, + velocity_bias_mode="off", velocity_bias_strength=0.0, + velocity_bias_source="mean", velocity_refinement_key="default", + rescue_mode=False, rescue_threshold=0.15, rescue_strength=0.2, + rescue_prompt_sig=None): + """Deterministic 1st-order euler (rectified-flow) sampler + video-only latent normalization. + + The plain euler base gives the clean 'ddim look' and clean audio (no AB2/Heun/ancestral, so + audio rides the same plain euler update). On top, per-step latent normalization keeps the + VIDEO latent's statistics in a healthy range to counter overbaking / oversaturation / colour + drift — the closest thing to a free, benefit-only quality lever for distilled LTXAV at CFG=1. + Velocity bias + reactive rescue are available too; everything except the deterministic euler + update is confined to the video stream, so audio stays clean. + """ + extra_args = {} if extra_args is None else extra_args + total_steps = max(0, len(sigmas) - 1) + if total_steps <= 0: + return x + normalize_strength = max(0.0, min(1.0, float(normalize_strength))) + normalize_start_sigma = max(0.0, min(1.0, float(normalize_start_sigma))) + + _RESCUE_LOG["warned_no_memory"] = False + _RESCUE_LOG["warned_no_prompt_match"] = False + _RESCUE_LOG["fired"] = 0 + + video_mask = _packed_video_mask(model, x) + if video_mask is not None: + n_aud = int((video_mask < 0.5).sum().item()) + print(f"[FunPack AV] normalizing sampler: audio-safe (audio held deterministic on " + f"{n_aud} of {video_mask.shape[-1]} packed dims; normalization video-only)") + + s_in = x.new_ones([x.shape[0]]) + ref_scale = [None] + + for i in comfy.utils.model_trange(total_steps, disable=disable): + sigma = sigmas[i] + sigma_next = sigmas[i + 1] + velocity_target = _velocity_bias_target(sigmas, sigma) + + if _velocity_bias_enabled(velocity_bias_mode, "apply"): + x_pre = x + x = _apply_velocity_bias(x, velocity_refinement_key, velocity_target, velocity_bias_strength, + sigma_ratio=_sigma_ratio(sigmas, sigma), + prompt_sig=rescue_prompt_sig, source=velocity_bias_source) + x = _video_only(x, x_pre, video_mask) + + denoised = model(x, sigma * s_in, **extra_args) + + if rescue_mode or _velocity_bias_enabled(velocity_bias_mode, "capture"): + _capture_velocity_bias(velocity_refinement_key, velocity_target, x, sigma, denoised, prompt_sig=rescue_prompt_sig) + if rescue_mode and velocity_target is not None: + denoised = _video_only(_rescue_denoised( + denoised, x, sigma, velocity_refinement_key, + velocity_target, rescue_threshold, rescue_strength, prompt_sig=rescue_prompt_sig, + source=velocity_bias_source, + ), denoised, video_mask) + + # The point of this sampler: video-only latent normalization (anti-overbake). + denoised = _normalize_video_denoised( + denoised, video_mask, sigma, ref_scale, normalize_strength, normalize_start_sigma, + ) + + if callback is not None: + callback({"x": x, "i": i, "sigma": sigma, "sigma_hat": sigma, "denoised": denoised}) + + if sigma_next == 0: + x = denoised + continue + dt = sigma_next - sigma # negative: sigmas decrease + d = k_diffusion_sampling.to_d(x, sigma, denoised) + x = x + d * dt + + return x + + +class FunPackNormalizingSampler: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "normalize_strength": ("FLOAT", { + "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, + "tooltip": "How hard to pull the video latent's spread back toward its healthy reference when it inflates (overbaking). 0 = plain euler, no normalization. 0.5 = gentle anti-overbake. 1.0 = clamp spread to the reference. Audio is never affected.", + }), + "normalize_start_sigma": ("FLOAT", { + "default": 0.9, "min": 0.0, "max": 1.0, "step": 0.025, + "tooltip": "Sigma at/below which normalization activates and the reference spread is anchored. Above this (pure-noise plateau) the x0 estimate is meaningless, so it's skipped. ~0.9 captures the reference at the structure-forming step.", + }), + "velocity_bias_mode": (VELOCITY_BIAS_MODES, { + "default": "off", + "tooltip": "Experimental: capture/apply averaged early model velocity (action) around the structure sigma. Off preserves the plain euler trajectory. Shares the same memory as the Hybrid/Distilled samplers.", + }), + "velocity_bias_strength": ("FLOAT", { + "default": 0.0, "min": 0.0, "max": 3.0, "step": 0.05, + "tooltip": "Strength of the remembered velocity (action) injected at the structure sigma. 0 disables. Creative/action tool, not a consistency lever.", + }), + "velocity_bias_source": (["mean", "nearest"], { + "default": "mean", + "tooltip": "How velocity bias / rescue pick a good direction. 'mean' = prompt-blind global average. 'nearest' = best-matching prompt cluster.", + }), + "velocity_refinement_key": ("STRING", { + "default": "default", "multiline": False, + "tooltip": "Memory key used to capture/apply velocity bias and rescue trajectories.", + }), + "rescue_mode": ("BOOLEAN", { + "default": False, + "tooltip": "Reactive in-flight rescue, rating-gated. Steers eligible steps toward trajectories you rated good and away from Awful ones (matched to the current prompt). No-op until a few gens are rated. Video-only.", + }), + "rescue_threshold": ("FLOAT", { + "default": 0.15, "min": 0.0, "max": 1.0, "step": 0.01, + "tooltip": "Fires when the step diverged from the good trajectory by more than this (1 - cosine) OR aligned with a bad one by more than this. Lower = corrects more eagerly.", + }), + "rescue_strength": ("FLOAT", { + "default": 0.2, "min": 0.0, "max": 0.5, "step": 0.01, + "tooltip": "How hard to pull toward good / away from bad when triggered (magnitude preserved). 0.5 is a strong correction.", + }), + }, + "optional": { + "sigmas": ("SIGMAS",), + } + } + + RETURN_TYPES = ("SAMPLER", "SIGMAS") + RETURN_NAMES = ("sampler", "sigmas") + FUNCTION = "get_sampler" + CATEGORY = "FunPack/Sampling" + DESCRIPTION = ( + "Deterministic 1st-order euler (rectified-flow) sampler with video-only latent " + "normalization to counter overbaking / oversaturation / colour drift at ~zero overhead. " + "Clean 'ddim look' and clean audio (audio rides plain euler); normalization and the " + "optional velocity bias + reactive rescue are confined to the video stream. " + "Built for distilled few-step LTXAV at CFG=1." + ) + + def get_sampler(self, normalize_strength=0.5, normalize_start_sigma=0.9, + velocity_bias_mode="off", velocity_bias_strength=0.0, + velocity_bias_source="mean", velocity_refinement_key="default", + rescue_mode=False, rescue_threshold=0.15, rescue_strength=0.2, + rescue_prompt_sig=None, sigmas=None): + prepared_sigmas = sigmas.detach().clone() if isinstance(sigmas, torch.Tensor) else sigmas + sampler = comfy.samplers.KSAMPLER( + sample_funpack_normalizing, + extra_options={ + "normalize_strength": normalize_strength, + "normalize_start_sigma": normalize_start_sigma, + "velocity_bias_mode": velocity_bias_mode, + "velocity_bias_strength": velocity_bias_strength, + "velocity_bias_source": velocity_bias_source, + "velocity_refinement_key": velocity_refinement_key, + "rescue_mode": rescue_mode, + "rescue_threshold": rescue_threshold, + "rescue_strength": rescue_strength, + "rescue_prompt_sig": rescue_prompt_sig, + } + ) + return (sampler, prepared_sigmas) + + class FunPackLTXAVSceneChainSampler: @classmethod def INPUT_TYPES(cls): diff --git a/web/funpack_studio.js b/web/funpack_studio.js index 834c6220..1c2b33cf 100644 --- a/web/funpack_studio.js +++ b/web/funpack_studio.js @@ -12,7 +12,7 @@ const TEMPORAL_STYLES = ["natural", "accelerate", "decelerate", "loop", "freeze" const SB_MODES = ["Pass-through", "Manual", "Auto", "Learning"]; const CATEGORY_ORDER = ["action", "camera", "subject", "appearance", "environment", "style", "quality", "details"]; const TABS = ["Session", "Scene", "Shortcuts", "Transitions", "Refiner", "Advisor", "LoRA", "Sampler", "Adjustments", "Timeline"]; -const SAMPLER_TYPES = ["Hybrid Euler 2S", "Distilled Flow", "KSampler"]; +const SAMPLER_TYPES = ["Hybrid Euler 2S", "Distilled Flow", "Normalizing", "KSampler"]; const MOTION_PULSE_MODES = ["off", "balanced", "aggressive", "custom"]; const VELOCITY_BIAS_MODES = ["off", "capture", "apply", "capture_and_apply"]; const KSAMPLER_NAMES = ["euler", "euler_ancestral", "dpm_2", "dpm_2_ancestral", "dpmpp_2m", "dpmpp_sde", "ddim", "uni_pc"]; @@ -74,8 +74,8 @@ function defaultSettings() { loras: [], loras_config: { mode: "ltx2", per_block: false }, samplers: { - high: { type: "Hybrid Euler 2S", sigmas: "", hybrid: { eta: 1.0, eta_final: 1.0, s_noise: 1.0, high_quality_pct: 0.35, correction_blend: 1.0, quality_sharpness: 0.0, motion_pulse_mode: "off", motion_pulse_start_pct: 0.3, motion_pulse_count: 2, motion_pulse_spacing_pct: 0.22, motion_pulse_strength: 0.85, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, distilled: { order: 2, final_correction_steps: 1, ab2_ramp: false, s_noise: 0.0, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, ksampler_name: "euler" }, - low: { type: "Distilled Flow", sigmas: "", hybrid: { eta: 1.0, eta_final: 1.0, s_noise: 1.0, high_quality_pct: 0.35, correction_blend: 1.0, quality_sharpness: 0.0, motion_pulse_mode: "off", motion_pulse_start_pct: 0.3, motion_pulse_count: 2, motion_pulse_spacing_pct: 0.22, motion_pulse_strength: 0.85, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, distilled: { order: 2, final_correction_steps: 1, ab2_ramp: false, s_noise: 0.0, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, ksampler_name: "euler" }, + high: { type: "Hybrid Euler 2S", sigmas: "", hybrid: { eta: 1.0, eta_final: 1.0, s_noise: 1.0, high_quality_pct: 0.35, correction_blend: 1.0, quality_sharpness: 0.0, motion_pulse_mode: "off", motion_pulse_start_pct: 0.3, motion_pulse_count: 2, motion_pulse_spacing_pct: 0.22, motion_pulse_strength: 0.85, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, distilled: { order: 2, final_correction_steps: 1, ab2_ramp: false, s_noise: 0.0, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, normalizing: { normalize_strength: 0.5, normalize_start_sigma: 0.9, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, ksampler_name: "euler" }, + low: { type: "Distilled Flow", sigmas: "", hybrid: { eta: 1.0, eta_final: 1.0, s_noise: 1.0, high_quality_pct: 0.35, correction_blend: 1.0, quality_sharpness: 0.0, motion_pulse_mode: "off", motion_pulse_start_pct: 0.3, motion_pulse_count: 2, motion_pulse_spacing_pct: 0.22, motion_pulse_strength: 0.85, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, distilled: { order: 2, final_correction_steps: 1, ab2_ramp: false, s_noise: 0.0, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, normalizing: { normalize_strength: 0.5, normalize_start_sigma: 0.9, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }, ksampler_name: "euler" }, }, }; } @@ -1568,6 +1568,48 @@ function openPanel(node) { dRnote.textContent = "Rating-gated: learns automatically from your ratings while on (good = steer toward, Awful = steer away). No-op until a few gens for this prompt are rated. Session reset clears it."; body.append(dRnote); } + } else if (cfg.type === "Normalizing") { + const nc = cfg.normalizing || (cfg.normalizing = { normalize_strength: 0.5, normalize_start_sigma: 0.9, velocity_bias_mode: "off", velocity_bias_strength: 0.0, velocity_bias_source: "mean", velocity_refinement_key: "default", rescue_mode: false, rescue_threshold: 0.15, rescue_strength: 0.2 }); + body.append(sectionTitle("Normalizing settings")); + body.append(el("div", "funpack-studio-hint", "Deterministic euler (clean 'ddim look' + clean audio) with video-only latent normalization to counter overbaking / oversaturation / colour drift at ~zero overhead. Audio is never touched. Built for distilled LTXAV at CFG=1.")); + const nStr = numInput(nc.normalize_strength, 0, 1, 0.05); + nStr.addEventListener("input", () => { nc.normalize_strength = parseFloat(nStr.value); }); + body.append(el("div", "funpack-studio-hint", "How hard to pull the video latent's spread back when it inflates (overbaking). 0 = plain euler. 0.5 = gentle. 1.0 = clamp to reference.")); + body.append(row("normalize strength", nStr)); + const nStart = numInput(nc.normalize_start_sigma, 0, 1, 0.025); + nStart.addEventListener("input", () => { nc.normalize_start_sigma = parseFloat(nStart.value); }); + body.append(el("div", "funpack-studio-hint", "Sigma at/below which normalization activates and anchors its reference. Above this (pure noise) it's skipped. ~0.9 = the structure-forming step.")); + body.append(row("normalize start sigma", nStart)); + const nVbm = selectEl(VELOCITY_BIAS_MODES, nc.velocity_bias_mode || "off"); + nVbm.addEventListener("change", () => { nc.velocity_bias_mode = nVbm.value; renderSampler(); }); + body.append(row("velocity bias mode", nVbm)); + if (nc.velocity_bias_mode && nc.velocity_bias_mode !== "off") { + const nVbs = numInput(nc.velocity_bias_strength, 0, 3.0, 0.05); + nVbs.addEventListener("input", () => { nc.velocity_bias_strength = parseFloat(nVbs.value); }); + body.append(row("velocity bias strength", nVbs)); + const nVbsrc = selectEl(["mean", "nearest"], nc.velocity_bias_source || "mean"); + nVbsrc.addEventListener("change", () => { nc.velocity_bias_source = nVbsrc.value; }); + body.append(row("bias source", nVbsrc)); + const nVrk = textInput(nc.velocity_refinement_key, "default"); + nVrk.addEventListener("input", () => { nc.velocity_refinement_key = nVrk.value; }); + body.append(row("velocity key", nVrk)); + } + const nRsm = selectEl(["off", "on"], nc.rescue_mode ? "on" : "off"); + nRsm.addEventListener("change", () => { nc.rescue_mode = (nRsm.value === "on"); renderSampler(); }); + body.append(row("rescue mode", nRsm)); + if (nc.rescue_mode) { + const nRst = numInput(nc.rescue_threshold, 0, 1, 0.01); + nRst.addEventListener("input", () => { nc.rescue_threshold = parseFloat(nRst.value); }); + body.append(row("rescue threshold", nRst)); + const nRss = numInput(nc.rescue_strength, 0, 0.5, 0.01); + nRss.addEventListener("input", () => { nc.rescue_strength = parseFloat(nRss.value); }); + body.append(row("rescue strength", nRss)); + if (!nc.velocity_bias_mode || nc.velocity_bias_mode === "off") { + const nRsrc = selectEl(["mean", "nearest"], nc.velocity_bias_source || "mean"); + nRsrc.addEventListener("change", () => { nc.velocity_bias_source = nRsrc.value; }); + body.append(row("bias source", nRsrc)); + } + } } else if (cfg.type === "KSampler") { body.append(sectionTitle("KSampler settings")); const ksSelect = selectEl(KSAMPLER_NAMES, cfg.ksampler_name || "euler");