Patch beta which is just Kumaraswamy (power-law s-curve) from overriding ComfyUI beta
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user