Patch beta which is just Kumaraswamy (power-law s-curve) from overriding ComfyUI beta

This commit is contained in:
Jordan Thompson
2026-05-07 20:47:25 -07:00
parent 57c22b59c7
commit b50eb993d8
5 changed files with 457 additions and 78 deletions
+5 -3
View File
@@ -36,8 +36,10 @@ If you're upgrading from an earlier version, note:
- **`karras_tan` is removed.** Use `atan_focused` or `atan_steep` for similar
shapes, or build a hybrid via the **Sigma Curves** node.
- **Sigma Curves** and **Manual Sampler** are new — see their sections below.
- New schedulers: `cosine`, `beta`, `laplacian`, `linear`, plus asymmetric
- New schedulers: `cosine`, `kumaraswamy`, `laplacian`, `linear`, plus asymmetric
`atan_structure` / `atan_detail` / `logistic_structure` / `logistic_detail`.
(ComfyUI's built-in `beta` is left alone — the Kumaraswamy curve we ship
is closed-form and a different shape, so it lives under its own name.)
Old names are explicitly unregistered on load, so saved workflows that
reference them will need to be repointed at the current equivalents.
@@ -114,7 +116,7 @@ on first use.
|-----------|-----------|
| `logistic` | sigmoid S-curve, sharper transition than `atan` |
| `cosine` | smoothest, no inflection |
| `beta` | beta-distribution-shaped, asymmetric tails |
| `kumaraswamy` | closed-form beta-like CDF, asymmetric tails (distinct from ComfyUI's `beta`) |
| `laplacian` | exponential decay through mid sigmas |
| `linear` | reference baseline |
@@ -301,7 +303,7 @@ update step. The 10 modes span 5 domains:
**Schedulers.** `atan_*` and `logistic_*` apply a curve function in two
stages (σ_max → σ_mid for structure, σ_mid → σ_min for detail), each with
its own slope normalized by step count. `cosine` / `beta` / `laplacian` /
its own slope normalized by step count. `cosine` / `kumaraswamy` / `laplacian` /
`linear` apply a single curve across the whole range. The `_structure` /
`_detail` variants make the two stages asymmetric.
+97 -35
View File
@@ -282,12 +282,40 @@ except Exception as _e: # noqa: BLE001
"(preview will use comfy's calculate_sigmas only).", _e)
def _res4lyf_special_sigmas(scheduler: str, model_sampling,
steps: int, denoise: float = 1.0):
"""Compute sigmas for RES4LYF schedulers that are *not* registered in
``comfy.samplers.SCHEDULER_HANDLERS``.
RES4LYF lists ``beta57`` in its custom dropdown (and uses it as the
default for many of its nodes) but never registers a handler -- it
short-circuits inside its own ``get_sigmas`` to
``beta_scheduler(model_sampling, steps, alpha=0.5, beta=0.7)``.
Calling ``calculate_sigmas`` for ``beta57`` therefore raises, which
is what made our preview drop to a linear-ramp stub.
Returns a sigmas tensor (with denoise crop applied like
BasicScheduler does) or ``None`` if *scheduler* is not a special.
"""
if scheduler != "beta57":
return None
beta_fn = getattr(comfy_samplers, "beta_scheduler", None)
if beta_fn is None:
return None
if denoise >= 1.0 or denoise <= 0.0:
return beta_fn(model_sampling, steps, alpha=0.5, beta=0.7)
total_steps = int(steps / max(denoise, 1e-4))
sigmas = beta_fn(model_sampling, total_steps, alpha=0.5, beta=0.7)
return sigmas[-(steps + 1):]
def _compute_scheduler_sigmas(model_sampling, scheduler: str, steps: int):
"""Get a sigmas list for *scheduler* + *steps*.
Tries the cached RES4SHO ``_SCHEDULERS`` dict first so our schedulers
work regardless of whether comfy's ``SCHEDULER_HANDLERS`` picked them
up. Falls back to comfy's ``calculate_sigmas`` otherwise.
up. Then handles RES4LYF specials (``beta57``). Falls back to comfy's
``calculate_sigmas`` otherwise.
Returns ``(sigmas_list, dispatch_label)`` -- a tuple so the endpoint
can surface which path was used. ``sigmas_list`` is ``None`` only when
@@ -305,6 +333,15 @@ def _compute_scheduler_sigmas(model_sampling, scheduler: str, steps: int):
scheduler, e)
return None, f"res4sho_error: {e!r}"
try:
special = _res4lyf_special_sigmas(scheduler, model_sampling, steps)
if special is not None:
return special.cpu().tolist(), "res4lyf_special"
except Exception as e: # noqa: BLE001
LOGGER.warning(
"SigmaCurves: RES4LYF special '%s' raised: %s", scheduler, e)
return None, f"res4lyf_special_error: {e!r}"
try:
sigmas = comfy_samplers.calculate_sigmas(
model_sampling, scheduler, steps)
@@ -430,18 +467,24 @@ def _register_routes():
f"not return a MODEL output."}, status=404)
bs = _BASIC_SCHEDULER or _get_basic_scheduler()
if bs is None:
return web.json_response(
{"error": "BasicScheduler unavailable"}, status=500)
ms = patcher.get_model_object("model_sampling")
try:
sigmas_tensor = bs.get_sigmas(
patcher, scheduler, steps, 1.0)[0].cpu()
# RES4LYF specials (beta57) aren't registered in
# SCHEDULER_HANDLERS, so BasicScheduler would raise.
special = _res4lyf_special_sigmas(scheduler, ms, steps)
if special is not None:
sigmas_tensor = special.cpu()
elif bs is None:
return web.json_response(
{"error": "BasicScheduler unavailable"}, status=500)
else:
sigmas_tensor = bs.get_sigmas(
patcher, scheduler, steps, 1.0)[0].cpu()
except Exception as e: # noqa: BLE001
LOGGER.error("BasicScheduler call failed: %s", e, exc_info=True)
return web.json_response(
{"error": f"BasicScheduler failed: {e!r}"}, status=500)
ms = patcher.get_model_object("model_sampling")
sigma_min = float(ms.sigma_min)
sigma_max = float(ms.sigma_max)
_cache_real_sigmas(scheduler, steps, sigmas_tensor,
@@ -500,29 +543,40 @@ def _register_routes():
patcher = _get_loaded_model_patcher()
bs = _BASIC_SCHEDULER or _get_basic_scheduler()
if patcher is not None and bs is not None:
if patcher is not None:
try:
sigmas_tensor = bs.get_sigmas(
patcher, scheduler, int(steps), 1.0)[0].cpu()
ms = patcher.get_model_object("model_sampling")
sigma_min_real = float(ms.sigma_min)
sigma_max_real = float(ms.sigma_max)
# Cache so the next request short-circuits to path #1
# AND so the cache survives a ComfyUI restart on disk.
_cache_real_sigmas(
scheduler, int(steps),
sigmas_tensor, sigma_min_real, sigma_max_real,
)
cached = _REAL_SIGMA_CACHE.get((scheduler, int(steps)))
if cached is not None:
return web.json_response({
"values": cached["values"],
"raw_sigmas": cached.get("raw_sigmas"),
"trailing_zero": cached.get("trailing_zero", True),
"dispatch": "real_model_live",
"sigma_min": sigma_min_real,
"sigma_max": sigma_max_real,
})
# RES4LYF specials (beta57) bypass BasicScheduler --
# they aren't registered in SCHEDULER_HANDLERS, so
# bs.get_sigmas would raise.
special = _res4lyf_special_sigmas(scheduler, ms,
int(steps))
if special is not None:
sigmas_tensor = special.cpu()
elif bs is not None:
sigmas_tensor = bs.get_sigmas(
patcher, scheduler, int(steps), 1.0)[0].cpu()
else:
sigmas_tensor = None
if sigmas_tensor is not None:
sigma_min_real = float(ms.sigma_min)
sigma_max_real = float(ms.sigma_max)
_cache_real_sigmas(
scheduler, int(steps),
sigmas_tensor, sigma_min_real, sigma_max_real,
)
cached = _REAL_SIGMA_CACHE.get(
(scheduler, int(steps)))
if cached is not None:
return web.json_response({
"values": cached["values"],
"raw_sigmas": cached.get("raw_sigmas"),
"trailing_zero": cached.get("trailing_zero", True),
"dispatch": "real_model_live",
"sigma_min": sigma_min_real,
"sigma_max": sigma_max_real,
})
except Exception as e: # noqa: BLE001
LOGGER.debug(
"BasicScheduler live preview failed for %s: %s",
@@ -838,14 +892,22 @@ class SigmaCurves:
# Use the stock BasicScheduler node directly. It does the right
# thing for every scheduler against any connected model and is
# what the user expects "scheduler with model + denoise" to mean.
bs = _BASIC_SCHEDULER or _get_basic_scheduler()
if bs is None:
raise RuntimeError("BasicScheduler unavailable -- "
"comfy_extras.nodes_custom_sampler not "
"importable.")
base_used = bs.get_sigmas(model, scheduler, steps, denoise)[0].cpu()
# RES4LYF's `beta57` is the one exception -- it isn't registered
# in SCHEDULER_HANDLERS, so we mirror their special case
# (beta_scheduler with alpha=0.5, beta=0.7) ourselves.
model_sampling = model.get_model_object("model_sampling")
special = _res4lyf_special_sigmas(scheduler, model_sampling,
steps, denoise)
if special is not None:
base_used = special.cpu()
else:
bs = _BASIC_SCHEDULER or _get_basic_scheduler()
if bs is None:
raise RuntimeError("BasicScheduler unavailable -- "
"comfy_extras.nodes_custom_sampler not "
"importable.")
base_used = bs.get_sigmas(model, scheduler, steps, denoise)[0].cpu()
sigma_min_real = float(model_sampling.sigma_min)
sigma_max_real = float(model_sampling.sigma_max)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "RES4SHO"
version = "1.4.0"
version = "1.4.1"
description = "High-Frequency Detail Sampling based on Res Sampling for ComfyUI"
readme = "README.md"
requires-python = ">=3.10"
+59 -23
View File
@@ -53,7 +53,7 @@ hfe_auto adds a few scalar ops on top.
import math
import logging
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
import torch
import torch.nn.functional as F
@@ -1828,28 +1828,28 @@ def _curve_cosine(xs: torch.Tensor, pivot: float, slope: float) -> torch.Tensor:
return (1.0 + torch.cos(math.pi * t_warped)) / 2.0
def _curve_beta(xs: torch.Tensor, pivot: float, slope: float) -> torch.Tensor:
"""Beta / power-law S-curve (inherently asymmetric).
def _curve_kumaraswamy(xs: torch.Tensor, pivot: float, slope: float) -> torch.Tensor:
"""Kumaraswamy power-law S-curve (inherently asymmetric).
Uses the regularized incomplete beta function CDF. *slope* controls
the concentration exponent (higher = stronger bend). The *pivot*
sets the balance between head and tail weighting: pivot < n/2 biases
Uses the Kumaraswamy CDF as a closed-form approximation of the
regularized incomplete beta function. *slope* controls the
concentration exponent (higher = stronger bend). The *pivot* sets
the balance between head and tail weighting: pivot < n/2 biases
toward early steps, pivot > n/2 biases toward late steps.
Note: this is NOT ComfyUI's BetaSchedulerNode (`scipy.stats.beta.ppf`
over the model's timestep table). Different math, different shape.
"""
n = xs.shape[0]
if n <= 1:
return torch.ones_like(xs)
t = (xs - xs[0]) / (xs[-1] - xs[0]) # [0, 1]
# Derive alpha/beta from slope and pivot position
pivot_norm = max(min(pivot / max(n - 1, 1), 0.95), 0.05)
concentration = max(slope * 5.0, 0.1) # scale slope to useful range
alpha = concentration * (1.0 - pivot_norm)
beta_p = concentration * pivot_norm
# Beta CDF via element-wise power approximation (Kumaraswamy)
a = max(alpha, 0.01)
b = max(beta_p, 0.01)
concentration = max(slope * 5.0, 0.1)
a = max(concentration * (1.0 - pivot_norm), 0.01)
b = max(concentration * pivot_norm, 0.01)
cdf = 1.0 - (1.0 - t.clamp(1e-7, 1.0 - 1e-7).pow(a)).pow(b)
return 1.0 - cdf # decreasing
return 1.0 - cdf
def _curve_laplacian(xs: torch.Tensor, pivot: float, slope: float) -> torch.Tensor:
@@ -2035,11 +2035,15 @@ def scheduler_cosine(model_sampling: Any, steps: int) -> torch.Tensor:
name='cosine')
def scheduler_beta(model_sampling: Any, steps: int) -> torch.Tensor:
"""Beta power-law S-curve (inherently asymmetric concentration)."""
return _tangent_schedule(model_sampling, steps, _curve_beta,
def scheduler_kumaraswamy(model_sampling: Any, steps: int) -> torch.Tensor:
"""Kumaraswamy power-law S-curve (inherently asymmetric concentration).
Distinct from ComfyUI's `beta` (BetaSchedulerNode) — see
`_curve_kumaraswamy` for the math.
"""
return _tangent_schedule(model_sampling, steps, _curve_kumaraswamy,
slope_1=0.20, slope_2=0.20,
name='beta')
name='kumaraswamy')
def scheduler_laplacian(model_sampling: Any, steps: int) -> torch.Tensor:
@@ -2119,7 +2123,7 @@ _SCHEDULERS = {
# Alternative curves
"logistic": scheduler_logistic,
"cosine": scheduler_cosine,
"beta": scheduler_beta,
"kumaraswamy": scheduler_kumaraswamy,
"laplacian": scheduler_laplacian,
"linear": scheduler_linear,
# Asymmetric presets
@@ -2196,7 +2200,21 @@ def _unregister_old() -> None:
def _register_samplers() -> None:
kdiff = getattr(comfy_samplers, "k_diffusion_sampling", None)
registered: List[str] = []
skipped: List[str] = []
for name, func in _SAMPLERS.items():
# Refuse to overwrite an existing sampler — even if the name is
# only present on kdiff (e.g. a built-in `sample_<name>`), bail
# so we don't shadow upstream behavior.
kdiff_attr = f"sample_{name}"
if kdiff is not None and hasattr(kdiff, kdiff_attr) \
and getattr(kdiff, kdiff_attr) is not func:
LOGGER.warning(
"RES4SHO: refusing to overwrite existing sampler '%s' "
"on k_diffusion_sampling.", name)
skipped.append(name)
continue
ksampler_names = getattr(comfy_samplers, "KSAMPLER_NAMES", None)
if isinstance(ksampler_names, (list, tuple)):
kl = list(ksampler_names)
@@ -2219,16 +2237,30 @@ def _register_samplers() -> None:
KSampler.SAMPLERS = sl
if kdiff is not None:
attr = f"sample_{name}"
setattr(kdiff, attr, func)
setattr(kdiff, kdiff_attr, func)
registered.append(name)
LOGGER.info("HFE samplers registered: %s", list(_SAMPLERS.keys()))
LOGGER.info("HFE samplers registered: %s", registered)
if skipped:
LOGGER.warning("HFE samplers skipped (name collision): %s", skipped)
def _register_schedulers() -> None:
handlers = getattr(comfy_samplers, "SCHEDULER_HANDLERS", None)
registered: List[str] = []
skipped: List[str] = []
for name, func in _SCHEDULERS.items():
# Refuse to overwrite an existing handler — most importantly,
# ComfyUI's built-in `beta` (BetaSchedulerNode), which is a
# different math from anything we ship.
if isinstance(handlers, dict) and name in handlers:
LOGGER.warning(
"RES4SHO: refusing to overwrite existing scheduler "
"'%s' in SCHEDULER_HANDLERS.", name)
skipped.append(name)
continue
if isinstance(handlers, dict) and len(handlers) > 0:
any_handler = next(iter(handlers.values()))
HandlerType = type(any_handler)
@@ -2249,8 +2281,12 @@ def _register_schedulers() -> None:
if name not in sched_list:
sched_list.append(name)
KSampler.SCHEDULERS = sched_list
registered.append(name)
LOGGER.info("HFE schedulers registered: %s", list(_SCHEDULERS.keys()))
LOGGER.info("HFE schedulers registered: %s", registered)
if skipped:
LOGGER.warning(
"HFE schedulers skipped (name collision): %s", skipped)
# =====================================================================
+295 -16
View File
@@ -15,8 +15,13 @@ import { api } from "../../scripts/api.js";
// "custom" is the no-op option: picking any other curve immediately
// reshapes the selected range (or the whole curve, if no range is set)
// using that interpolation. Picking "custom" leaves edits alone.
//
// "bezier" is special -- instead of a closed-form interp from yA to yB
// it draws two draggable handles inside the active range and produces
// a cubic Bezier through them. The middle values fill in automatically
// as the user drags, no separate "apply" needed.
const INTERP_OPTIONS = [
"custom",
"custom", "bezier",
"linear", "step", "step_next", "smoothstep", "smootherstep", "cosine",
"sigmoid", "atan", "ease_in", "ease_out", "ease_in_out", "exp",
];
@@ -71,7 +76,7 @@ const SEG_FNS = {
};
function applyRangeCurve(values, a, b, interp, tension) {
if (!interp || interp === "custom") return;
if (!interp || interp === "custom" || interp === "bezier") return;
if (a === b) return;
if (a > b) { const t = a; a = b; b = t; }
const yA = values[a], yB = values[b];
@@ -84,6 +89,75 @@ function applyRangeCurve(values, a, b, interp, tension) {
}
}
// ----- Cubic Bezier --------------------------------------------------
// Handles are stored as { x, y } both normalized in [0, 1] within the
// active range. Endpoints are the user's current values[a] / values[b],
// so the curve always starts and ends at hand-edited positions; only
// the middle is shaped by the handles.
function _bezier1D(t, p0, p1, p2, p3) {
const u = 1 - t;
return u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3;
}
// Cubic Bezier x(t) = u_target. Monotone in x as long as
// 0 <= h0.x <= h1.x <= 1. Binary search is overkill but stable and
// dirt cheap (24 iters ≈ 1e-7 precision).
function _bezierTForX(u_target, p1x, p2x) {
let lo = 0, hi = 1;
for (let i = 0; i < 24; i++) {
const m = (lo + hi) * 0.5;
const x = _bezier1D(m, 0, p1x, p2x, 1);
if (x < u_target) lo = m;
else hi = m;
}
return (lo + hi) * 0.5;
}
function applyBezierRange(values, a, b, h0, h1) {
if (a === b) return;
if (a > b) { const t = a; a = b; b = t; }
const span = b - a;
if (span < 2) return;
const yA = values[a], yB = values[b];
const h0x = clamp(h0?.x ?? 1/3, 0, 1);
const h1x = clamp(h1?.x ?? 2/3, 0, 1);
const h0y = clamp(h0?.y ?? yA, 0, 1);
const h1y = clamp(h1?.y ?? yB, 0, 1);
for (let i = a + 1; i < b; i++) {
const u = (i - a) / span;
const t = _bezierTForX(u, h0x, h1x);
values[i] = clamp(_bezier1D(t, yA, h0y, h1y, yB), 0, 1);
}
}
// Pick handles that approximately reproduce the existing curve shape
// across [a..b] when switching INTO bezier mode. Solves for h0.y, h1.y
// such that B(1/3) ≈ values[i1] and B(2/3) ≈ values[i2] with handles
// fixed at x=1/3 and x=2/3. Means flipping to bezier doesn't immediately
// distort what the user has.
function _fitBezierHandles(values, a, b) {
const span = b - a;
if (span < 2) return [{ x: 1/3, y: 0.66 }, { x: 2/3, y: 0.33 }];
const yA = values[a], yB = values[b];
const i1 = a + Math.max(1, Math.round(span / 3));
const i2 = a + Math.max(1, Math.round(2 * span / 3));
const y1 = values[i1] ?? (yA + (yB - yA) / 3);
const y2 = values[i2] ?? (yA + 2 * (yB - yA) / 3);
// B(1/3) = (8 yA + 12 h0y + 6 h1y + yB) / 27
// B(2/3) = (yA + 6 h0y + 12 h1y + 8 yB) / 27
const r1 = 27*y1 - 8*yA - yB;
const r2 = 27*y2 - yA - 8*yB;
// Solve: 12 h0y + 6 h1y = r1; 6 h0y + 12 h1y = r2
const det = 12*12 - 6*6; // 108
const h0y = ( 12*r1 - 6*r2) / det;
const h1y = (-6*r1 + 12*r2) / det;
return [
{ x: 1/3, y: clamp(h0y, 0, 1) },
{ x: 2/3, y: clamp(h1y, 0, 1) },
];
}
// ----- Themed dialogs -------------------------------------------------
//
// Native browser prompt/alert/confirm look jarring next to ComfyUI's UI
@@ -532,9 +606,9 @@ const TOOLBAR_KEYS = ["curve", "tension", "apply", "all", "clear", "flat",
"reset", "save", "load", "del"];
const TOOLBAR_TOOLTIPS = {
curve: "curve type — interpolation across the active range",
tension: "curve tension (sigmoid / atan / ease / exp shape)",
apply: "apply curve to range (or whole curve if no selection)",
curve: "curve type — interp across active range; 'bezier' adds drag handles",
tension: "curve tension (sigmoid / atan / ease / exp); ignored by bezier",
apply: "apply curve from range start→end; mid-points overwritten (use bezier to shape with handles)",
all: "select all steps as the active range",
clear: "clear range selection",
flat: "flatten range to its start value",
@@ -616,6 +690,16 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
fromRealModel: false, // true when the displayed curve was sourced
// from a previous SigmaCurves.build() run
// against the user's actual model.
userEdited: false, // set true when the user mutates values (drag,
// apply, flatten, preset load, bezier handle).
// Reset by refreshBaseline. Gates the
// sigmas_updated websocket auto-refresh so a
// workflow run cannot clobber hand edits.
// Bezier handle state (active only when interp === "bezier").
// x/y are normalized [0, 1] within the active range.
bezierH0: { x: 0.33, y: 0.66 },
bezierH1: { x: 0.67, y: 0.33 },
bezierDrag: null, // "h0" | "h1" | null
};
function syncFromDataWidget() {
@@ -623,15 +707,39 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
if (v === state.lastValueSeen) return;
state.lastValueSeen = v;
if (!v) return;
let restored = false;
try {
const obj = JSON.parse(v);
if (Array.isArray(obj.values) && obj.values.length >= 2) {
state.values = obj.values.map(x => clamp(+x, 0, 1));
restored = true;
}
if (typeof obj.scheduler === "string") state.scheduler = obj.scheduler;
if (typeof obj.steps === "number") state.steps = obj.steps | 0;
if (typeof obj.interp === "string") state.interp = obj.interp;
if (typeof obj.tension === "number") state.tension = obj.tension;
if (obj.bezierH0 && typeof obj.bezierH0.x === "number"
&& typeof obj.bezierH0.y === "number") {
state.bezierH0 = {
x: clamp(obj.bezierH0.x, 0, 1),
y: clamp(obj.bezierH0.y, 0, 1),
};
}
if (obj.bezierH1 && typeof obj.bezierH1.x === "number"
&& typeof obj.bezierH1.y === "number") {
state.bezierH1 = {
x: clamp(obj.bezierH1.x, 0, 1),
y: clamp(obj.bezierH1.y, 0, 1),
};
}
if (typeof obj.userEdited === "boolean") {
state.userEdited = obj.userEdited;
} else if (restored) {
// Curve_data restored from a workflow without an explicit
// userEdited flag (older saves). Treat as edited so the
// sigmas_updated auto-refresh doesn't clobber it.
state.userEdited = true;
}
} catch (e) { /* keep what we have */ }
// Reconcile with the live steps widget. ComfyUI's workflow
@@ -675,6 +783,15 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
steps: state.steps,
interp: state.interp,
tension: state.tension,
bezierH0: {
x: +state.bezierH0.x.toFixed(6),
y: +state.bezierH0.y.toFixed(6),
},
bezierH1: {
x: +state.bezierH1.x.toFixed(6),
y: +state.bezierH1.y.toFixed(6),
},
userEdited: !!state.userEdited,
};
const json = JSON.stringify(obj);
dataWidget.value = json;
@@ -698,7 +815,13 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
hi = state.values.length - 1;
}
if (hi - lo < 2) return;
applyRangeCurve(state.values, lo, hi, state.interp, state.tension);
if (state.interp === "bezier") {
applyBezierRange(state.values, lo, hi,
state.bezierH0, state.bezierH1);
} else {
applyRangeCurve(state.values, lo, hi, state.interp, state.tension);
}
state.userEdited = true;
pushToDataWidget();
}
@@ -708,10 +831,27 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
new LiteGraph.ContextMenu(choices, {
event,
callback: (selected) => {
if (typeof selected === "string") {
state.interp = selected;
pushToDataWidget();
if (typeof selected !== "string") return;
const wasBezier = state.interp === "bezier";
state.interp = selected;
// Switching INTO bezier mode: fit the handles to
// the current curve shape so the visible curve
// doesn't jump. Switching OUT: keep handles in
// state so re-entering picks up where we left.
if (selected === "bezier" && !wasBezier) {
const lo = (state.selStart >= 0 && state.selEnd >= 0)
? Math.min(state.selStart, state.selEnd) : 0;
const hi = (state.selStart >= 0 && state.selEnd >= 0)
? Math.max(state.selStart, state.selEnd)
: (state.values?.length ?? 1) - 1;
if (state.values && hi - lo >= 2) {
const [h0, h1] = _fitBezierHandles(
state.values, lo, hi);
state.bezierH0 = h0;
state.bezierH1 = h1;
}
}
pushToDataWidget();
},
});
} else {
@@ -771,6 +911,8 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
state.fromRealModel = !!result.from_real_model;
state.selStart = state.selEnd = -1;
state.lastFetched = { scheduler: sch, steps: stp };
// Fresh baseline -- not user-edited until they touch it.
state.userEdited = false;
pushToDataWidget();
}
@@ -873,6 +1015,7 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
const hi = Math.max(state.selStart, state.selEnd);
const v = state.values[lo];
for (let i = lo; i <= hi; i++) state.values[i] = v;
state.userEdited = true;
pushToDataWidget();
}
@@ -932,6 +1075,9 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
stepsWidget.value = p.steps;
state.steps = p.steps;
}
// Loaded preset is intentional user state; protect from
// the sigmas_updated auto-refresh.
state.userEdited = true;
pushToDataWidget();
showToast(`Loaded "${selected}".`, "success", 2000);
},
@@ -972,7 +1118,10 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
function handleToolbarClick(key, event) {
switch (key) {
case "curve": showCurveDropdown(event); break;
case "tension": promptTension(); break;
case "tension":
if (state.interp === "bezier") return;
promptTension();
break;
case "apply": applyToSelection(); break;
case "all": selectAllRange(); break;
case "clear": clearRange(); break;
@@ -984,6 +1133,68 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
}
}
// Returns [lo, hi] = the active bezier range. With a selection,
// it's the selection; without, it's the whole curve.
function _activeRange() {
if (!state.values) return [-1, -1];
if (state.selStart >= 0 && state.selEnd >= 0) {
return [
Math.min(state.selStart, state.selEnd),
Math.max(state.selStart, state.selEnd),
];
}
return [0, state.values.length - 1];
}
// Convert bezier handle (range-normalized x ∈ [0,1], y ∈ [0,1]) to
// plot coordinates given the active range and rect.
function _handlePlotPos(rect, handle) {
if (!state.values) return [0, 0];
const [lo, hi] = _activeRange();
if (hi - lo < 2) return [0, 0];
const n = state.values.length;
const tA = lo / (n - 1);
const tB = hi / (n - 1);
const ht = tA + clamp(handle.x, 0, 1) * (tB - tA);
return dataToPlot(rect, ht, clamp(handle.y, 0, 1));
}
function _findBezierHandle(rect, px, py) {
if (state.interp !== "bezier" || !state.values) return null;
const [lo, hi] = _activeRange();
if (hi - lo < 2) return null;
for (const [key, h] of [["h0", state.bezierH0], ["h1", state.bezierH1]]) {
const [hx, hy] = _handlePlotPos(rect, h);
const dx = px - hx, dy = py - hy;
if (dx*dx + dy*dy <= (HIT_R + 2) * (HIT_R + 2)) return key;
}
return null;
}
function _setBezierHandleFromPlot(key, rect, px, py) {
const [lo, hi] = _activeRange();
if (hi - lo < 2) return;
const n = state.values.length;
const tA = lo / (n - 1), tB = hi / (n - 1);
if (tB <= tA) return;
const t = clamp((px - rect.x) / rect.w, tA, tB);
const xNorm = (t - tA) / (tB - tA);
const yVal = plotToValue(rect, py);
const h = key === "h0" ? state.bezierH0 : state.bezierH1;
h.x = clamp(xNorm, 0, 1);
h.y = clamp(yVal, 0, 1);
// Keep h0.x <= h1.x for a monotone-x Bezier (avoids loops).
if (state.bezierH0.x > state.bezierH1.x) {
const tmp = state.bezierH0.x;
state.bezierH0.x = state.bezierH1.x;
state.bezierH1.x = tmp;
}
applyBezierRange(state.values, lo, hi,
state.bezierH0, state.bezierH1);
state.userEdited = true;
pushToDataWidget();
}
function findStep(rect, px, py) {
if (!state.values) return -1;
const n = state.values.length;
@@ -1106,6 +1317,45 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
ctx.stroke();
}
// Bezier handles (only in bezier mode). Drawn after dots so
// the handles sit on top of regular step markers.
if (state.interp === "bezier") {
const [lo, hi] = _activeRange();
if (hi - lo >= 2) {
const [aPx, aPy] = dataToPlot(rect, lo / (n - 1),
state.values[lo]);
const [bPx, bPy] = dataToPlot(rect, hi / (n - 1),
state.values[hi]);
const [h0x, h0y] = _handlePlotPos(rect, state.bezierH0);
const [h1x, h1y] = _handlePlotPos(rect, state.bezierH1);
// Stems from anchors to handles.
ctx.strokeStyle = "rgba(255, 200, 0, 0.55)";
ctx.setLineDash([3, 3]);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ox + aPx, oy + aPy);
ctx.lineTo(ox + h0x, oy + h0y);
ctx.moveTo(ox + bPx, oy + bPy);
ctx.lineTo(ox + h1x, oy + h1y);
ctx.stroke();
ctx.setLineDash([]);
// Handle dots (square so they're distinct from steps).
for (const [hx, hy, isDrag] of [
[h0x, h0y, state.bezierDrag === "h0"],
[h1x, h1y, state.bezierDrag === "h1"],
]) {
const r = isDrag ? 6 : 5;
ctx.fillStyle = "#fc0";
ctx.fillRect(ox + hx - r, oy + hy - r, 2*r, 2*r);
ctx.strokeStyle = "#000";
ctx.lineWidth = 1.5;
ctx.strokeRect(ox + hx - r + 0.5,
oy + hy - r + 0.5,
2*r - 1, 2*r - 1);
}
}
}
// Hover label (after dots so it sits on top)
if (state.hover >= 0 && state.values[state.hover] !== undefined) {
const i = state.hover;
@@ -1167,10 +1417,12 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
drawToolbarButton(ctx, tb.curve, ox, oy, `${state.interp} ▾`,
{ active: state.interp !== "custom",
hover: state.toolbarHover === "curve" });
const bezierMode = state.interp === "bezier";
drawToolbarButton(ctx, tb.tension, ox, oy,
`k ${state.tension.toFixed(2)}`,
{ active: state.tension !== 0,
hover: state.toolbarHover === "tension" });
bezierMode ? "k —" : `k ${state.tension.toFixed(2)}`,
{ active: !bezierMode && state.tension !== 0,
hover: !bezierMode && state.toolbarHover === "tension",
disabled: bezierMode });
drawToolbarButton(ctx, tb.apply, ox, oy, "apply",
{ active: canApply,
hover: canApply && state.toolbarHover === "apply",
@@ -1272,6 +1524,15 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
return true;
}
// Bezier handle drag takes priority over step-dot drag.
if (state.interp === "bezier") {
const handle = _findBezierHandle(rect, localX, localY);
if (handle) {
state.bezierDrag = handle;
return true;
}
}
// Plain left-click: drag the y of the nearest dot if it
// was clicked on; otherwise no-op (leaves the selection
// intact so the user can apply curves repeatedly).
@@ -1286,6 +1547,12 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
if (evType === "pointermove" || evType === "mousemove") {
// Toolbar hover already updated above.
// Bezier handle drag.
if (state.bezierDrag) {
_setBezierHandleFromPlot(state.bezierDrag, rect,
localX, localY);
return true;
}
// Right-drag to extend the range.
if (state.rightDragging) {
state.selEnd = stepFromX(localX);
@@ -1296,6 +1563,7 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
// Left-drag to update the y of the held dot.
if (state.dragging >= 0) {
state.values[state.dragging] = plotToValue(rect, localY);
state.userEdited = true;
pushToDataWidget();
return true;
}
@@ -1308,6 +1576,11 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
}
if (evType === "pointerup" || evType === "mouseup") {
if (state.bezierDrag) {
state.bezierDrag = null;
pushToDataWidget();
return true;
}
if (state.rightDragging) {
state.rightDragging = false;
event.preventDefault?.();
@@ -1405,6 +1678,7 @@ function makeStepCurveWidget(node, schedulerWidget, stepsWidget, dataWidget) {
setTimeout(ensureInitialized, 0);
widget._sigmaInit = ensureInitialized;
widget._sigmaSyncFromData = syncFromDataWidget;
widget._sigmaIsEdited = () => !!state.userEdited;
watchWidgets();
return widget;
@@ -1553,9 +1827,14 @@ if (typeof window !== "undefined" && !window.__res4sho_ws_listener) {
if (!sw || !tw) continue;
if (sch && sw.value !== sch) continue;
if (stp != null && tw.value !== stp) continue;
// Trigger a refresh by clearing dataWidget value so the
// node's draw() sync re-fetches. Or call refreshBaseline
// via the wrapped scheduler callback.
// CRITICAL: skip nodes the user has hand-edited. The
// sigmas_updated event was meant to snap a *fresh* node to
// the real-model shape on first run -- never to clobber
// a hand-shaped schedule.
const cw = (n.widgets || []).find(
w => w?.type === "sigma_curve_steps");
if (cw && typeof cw._sigmaIsEdited === "function"
&& cw._sigmaIsEdited()) continue;
if (typeof sw.callback === "function") {
try { sw.callback(sw.value, app.canvas, n); } catch (e) {}
}