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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-04 12:07:39 +03:00
co-authored by Claude Opus 4.8
parent 951c61953d
commit 49e7517389
5 changed files with 283 additions and 5 deletions
+2
View File
@@ -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
+5 -2
View File
@@ -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",
+23
View File
@@ -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")
+208
View File
@@ -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):
+45 -3
View File
@@ -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");