From b50eb993d86041d40b46795f098dc4d5533fda7d Mon Sep 17 00:00:00 2001 From: Jordan Thompson Date: Thu, 7 May 2026 20:47:25 -0700 Subject: [PATCH] Patch beta which is just Kumaraswamy (power-law s-curve) from overriding ComfyUI beta --- README.md | 8 +- nodes.py | 132 ++++++++++++++----- pyproject.toml | 2 +- sampling.py | 82 ++++++++---- web/sigma_curves.js | 311 +++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 457 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 4271be9..8f8032c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/nodes.py b/nodes.py index 2dc411e..a1c7a25 100644 --- a/nodes.py +++ b/nodes.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index bb4ba69..6aa67bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/sampling.py b/sampling.py index c66d237..13efc1f 100644 --- a/sampling.py +++ b/sampling.py @@ -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_`), 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) # ===================================================================== diff --git a/web/sigma_curves.js b/web/sigma_curves.js index 3690140..234d119 100644 --- a/web/sigma_curves.js +++ b/web/sigma_curves.js @@ -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) {} }