Fix viewer delivery, early-stop handling and slider usability (v1.0.2)

- Viewer: send step images as temp files instead of base64 (dead send_json call removed; no history/WebSocket bloat)
- Sampler: early stop returns the latest x0 and always saves the stop step; PSNR inf counts as converged
- Sampler: ignore auto_stop when stop_threshold is unsuitable for PSNR/SSIM (avoids stopping at step 2)
- Sampler: keep step images as uint8 in a local list; pop downscale_ratio_spacial; compat with older fix_empty_latent_channels
- Fix display name mapping for the sampler node
- Viewer JS: larger slider handle/track, wheel and keyboard stepping, clear timer on node removal, validate play speed
- README: fix typos and document threshold semantics / controls

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Takkun
2026-09-20 20:20:13 +09:00
co-authored by Claude Sonnet 5
parent c5888ecbe9
commit ab9d4036cd
6 changed files with 325 additions and 170 deletions
+12 -6
View File
@@ -10,11 +10,11 @@ A set of custom nodes for ComfyUI designed to analyze and compare the image gene
## Node description
### 1. Step-by-Step Sampler
Hooks into the generation process to capture intermediate images at specified intervals by VAE encoding the latent state. It can be integrated with `Preview Image` or `Save Image` nodes to display or output the progression as a sequence.
Hooks into the generation process to capture intermediate images at specified intervals by VAE decoding the latent state. It can be integrated with `Preview Image` or `Save Image` nodes to display or output the progression as a sequence.
Calculates the amount of change from the previous step using MSE, RMSE, L1, PSNR, or SSIM, allowing you to monitor convergence numerically.
Draws step counts and difference values directly onto the images, making it easy to track the step number and the delta from the preceding frame.
Since sampling automatically stops once image generation converges, image generation can be performed with the optimal number of steps.
Outputs the final result as a standard `LATENT` (similar to KSampler), but can also output the VAE-encoded image via the `LAST_IMAGE` socket.
Outputs the final result as a standard `LATENT` (similar to KSampler), but can also output the VAE-decoded image via the `LAST_IMAGE` socket.
<img src="images/StepByStep_Sampler.png">
@@ -30,8 +30,7 @@ A side-by-side visualization tool for the `STEP_IMAGES` output. It allows you to
## Installation
Manual Install
```
Bash
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/TakkunRed/ComfyUI-StepByStep-Sampler.git
```
@@ -44,6 +43,7 @@ git clone https://github.com/TakkunRed/ComfyUI-StepByStep-Sampler.git
* Run Queue Prompt as usual, and the generation process will appear in the viewers.
### Viewer Controls
* Sliders (Player / Comparer): click anywhere on the track to jump, drag the handle, or hover over a slider and use the **mouse wheel** (down = next step, up = previous step). Arrow keys / `Home` / `End` also work while the slider is focused.
* `Player`: Change steps using the bottom slider or use the "Play" button for auto-playback.
* `Comparer`: Select two steps to compare using the sliders, and drag the vertical bar on the image to reveal the differences between side A and side B.
@@ -59,8 +59,14 @@ git clone https://github.com/TakkunRed/ComfyUI-StepByStep-Sampler.git
PSNR → ∞ (Higher dB means closer to previous step; 40dB+ is a typical convergence target)
SSIM → 1.0 (Structural Similarity Index; 0.99+ is a typical convergence target)
```
* `auto_stop`: Enable/Disable automatic convergence stop
* `stop_threshhold`: Threshold for determining convergence
* `auto_stop`: Enable/Disable automatic convergence stop. When it stops early, the image of the stop step is always included in `STEP_IMAGES` (regardless of `save_interval`), and the returned `LATENT` is the state at the stop step.
* `stop_threshold`: Threshold for determining convergence. The direction depends on `diff_method`:
* `MSE` / `RMSE` / `L1`: stops when the difference is **at or below** the threshold (e.g. `0.0001`).
* `PSNR` / `SSIM`: stops when the value is **at or above** the threshold (e.g. `40` dB / `0.99`). If the threshold is clearly unsuitable for the method (PSNR below 10 dB, SSIM below 0.5 — e.g. the MSE-scale default left unchanged), `auto_stop` is ignored and a warning is printed to the console, instead of stopping at step 2.
### Notes
* `STEP_IMAGES` and the overlay use the first image of the batch. `LAST_IMAGE` contains the whole batch.
* The Player / Comparer viewers receive the step images as temporary JPEG files (`temp/step_by_step/`), not as base64 in the message/history, so long runs do not bloat the server history. Files of the previous run of the same node are removed automatically.
## License
MIT
+57 -32
View File
@@ -2,13 +2,11 @@ import torch
import torch.nn.functional as F
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import nodes
import comfy.samplers
import comfy.sample
import comfy.utils
import comfy.model_management
import latent_preview
from server import PromptServer
DIFF_METHODS = ["MSE", "RMSE", "L1", "PSNR", "SSIM"]
@@ -45,7 +43,10 @@ class StepByStepSampler:
# ---- 早期停止パラメータ ----
"auto_stop": ("BOOLEAN", {"default": False, "label_on": "ON", "label_off": "OFF"}),
"stop_threshold":("FLOAT", {"default": 0.0001, "min": 0.0, "max": 100.0,
"step": 0.0001, "round": False}),
"step": 0.0001, "round": False,
"tooltip": "MSE/RMSE/L1: この値以下で停止 / "
"PSNR(dB)・SSIM: この値以上で停止 "
"(目安: PSNR 40, SSIM 0.99)"}),
}}
RETURN_TYPES = ("LATENT", "IMAGE", "IMAGE", "INT")
@@ -116,12 +117,28 @@ class StepByStepSampler:
その他は値が小さいほど近い(threshold 以下で収束)。
"""
if method == "PSNR":
return diff_value != float("inf") and diff_value >= threshold
return diff_value >= threshold # inf(前ステップと完全一致)は収束済み
elif method == "SSIM":
return diff_value >= threshold
else:
return diff_value <= threshold
def _threshold_is_sane(self, threshold, method):
"""
PSNR / SSIM は「大きいほど近い」ため、MSE 系のしきい値(0.0001 等)のままだと
2 ステップ目で必ず停止してしまう。明らかに不適切な値のときは早期停止を行わない。
"""
if method == "PSNR":
return threshold >= 10.0
if method == "SSIM":
return threshold >= 0.5
return True
@staticmethod
def _to_uint8(img):
"""[1,H,W,C] float(0..1) -> uint8。全ステップ分を保持するためメモリを 1/4 にする"""
return (img.clamp(0.0, 1.0) * 255.0).round().to(torch.uint8)
# ------------------------------------------------------------------ #
# テキストオーバーレイ
# ------------------------------------------------------------------ #
@@ -163,8 +180,11 @@ class StepByStepSampler:
auto_stop=False, stop_threshold=0.0001):
latent = latent_image["samples"]
latent = comfy.sample.fix_empty_latent_channels(
model, latent, latent_image.get("downscale_ratio_spacial", None))
try:
latent = comfy.sample.fix_empty_latent_channels(
model, latent, latent_image.get("downscale_ratio_spacial", None))
except TypeError: # 古い ComfyUI(引数が 2 つのバージョン)
latent = comfy.sample.fix_empty_latent_channels(model, latent)
batch_inds = latent_image.get("batch_index", None)
noise = comfy.sample.prepare_noise(latent, seed, batch_inds)
@@ -172,23 +192,26 @@ class StepByStepSampler:
preview_callback = latent_preview.prepare_callback(model, steps)
self.step_images = []
step_images = [] # uint8。インスタンスには保持しない(実行後にメモリが残らない)
last_saved_step = [-1]
prev_x0_cpu = [None]
stopped_at = [steps] # 実際に停止したステップ数(1始まり)
last_x0_proc = [None] # 早期停止時の最終 x0 を保持
latest_x0 = [None] # 直近ステップの x0(早期停止時の最終結果に使う)
use_auto_stop = auto_stop
if auto_stop and not self._threshold_is_sane(stop_threshold, diff_method):
print(f"[StepSampler] auto_stop is disabled: stop_threshold={stop_threshold} is not "
f"suitable for {diff_method} (PSNR needs >= 10 dB e.g. 40, SSIM needs >= 0.5 e.g. 0.99).")
use_auto_stop = False
def callback(step, x0, x, total_steps):
preview_callback(step, x0, x, total_steps)
is_interval = (step % save_interval == 0)
is_last = (step == total_steps - 1)
should_save = (is_interval or is_last) and (step != last_saved_step[0])
with torch.no_grad():
# スケール変換(差分計算・早期停止判定に毎ステップ必要)
x0_proc = self._process_x0(model, x0)
x0_cpu = x0_proc.cpu()
latest_x0[0] = x0_proc
# 差分計算(CPU)
diff_value = None
@@ -199,10 +222,22 @@ class StepByStepSampler:
diff_str = self._format_diff(diff_value, diff_method)
prev_x0_cpu[0] = x0_cpu
# 保存対象ステップの処理
# ---- 早期停止判定 ----
# ・auto_stop が ON(かつしきい値が手法に対して妥当)
# ・diff_value が計算済み(= 2ステップ目以降)
# ・収束条件を満たした
# ・最終ステップでない(最終ステップは通常終了させる)
will_stop = (use_auto_stop and diff_value is not None
and self._converged(diff_value, stop_threshold, diff_method)
and step < total_steps - 1)
# 保存対象ステップの処理(停止するステップは間隔に関係なく必ず保存する)
is_interval = (step % save_interval == 0)
is_last = (step == total_steps - 1)
should_save = (is_interval or is_last or will_stop) and (step != last_saved_step[0])
if should_save:
last_saved_step[0] = step
last_x0_proc[0] = x0_proc # 最後に保存した x0 を記録
try:
decoded = self._safe_decode(vae, x0_proc).cpu()
@@ -214,16 +249,9 @@ class StepByStepSampler:
label = f"Step {step + 1}/{total_steps} {diff_method}: {diff_str}"
img = self._annotate(decoded, label) if show_overlay \
else decoded[0:1].cpu()
self.step_images.append(img)
step_images.append(self._to_uint8(img))
# ---- 早期停止判定 ----
# ・auto_stop が ON
# ・diff_value が計算済み(= 2ステップ目以降)
# ・収束条件を満たした
# ・最終ステップでない(最終ステップは通常終了させる)
if (auto_stop and diff_value is not None
and self._converged(diff_value, stop_threshold, diff_method)
and step < total_steps - 1):
if will_stop:
stopped_at[0] = step + 1 # 1始まりで記録
print(f"[StepSampler] Early stop at step {step + 1}/{total_steps} "
f"({diff_method}={diff_str} threshold={stop_threshold})")
@@ -236,7 +264,6 @@ class StepByStepSampler:
# 早期停止時は _EarlyStop 例外が raise される。
# その場合 samples は得られないので、最後に保存した x0 を代わりに使う。
# ------------------------------------------------------------------ #
early_stopped = False
try:
samples = comfy.sample.sample(
model, noise, steps, cfg, sampler_name, scheduler,
@@ -245,17 +272,14 @@ class StepByStepSampler:
callback=callback, disable_pbar=disable_pbar, seed=seed,
)
except _EarlyStop:
early_stopped = True
# 早期停止時は最後に保存した x0_proc をサンプルとして使う
if last_x0_proc[0] is not None:
samples = last_x0_proc[0].to(latent.device)
else:
samples = latent # フォールバック
# 早期停止時は停止したステップの x0(処理済み)をサンプルとして使う
samples = latest_x0[0].to(latent.device) if latest_x0[0] is not None else latent
# ------------------------------------------------------------------ #
# 出力整形
# ------------------------------------------------------------------ #
latent_out = latent_image.copy()
latent_out.pop("downscale_ratio_spacial", None) # 標準の KSampler と同じ
latent_out["samples"] = samples
# LAST_IMAGE:最終サンプル結果を VAE デコード(オーバーレイなし)
@@ -265,13 +289,14 @@ class StepByStepSampler:
print(f"[StepSampler] final decode failed: {e}")
last_image = torch.zeros(1, 64, 64, 3)
if not self.step_images:
if not step_images:
label = f"Step {stopped_at[0]}/{steps} {diff_method}: -"
step_imgs = self._annotate(last_image, label) if show_overlay \
else last_image[0:1]
return (latent_out, step_imgs, last_image, stopped_at[0])
return (latent_out, torch.cat(self.step_images, dim=0), last_image, stopped_at[0])
step_imgs = torch.cat(step_images, dim=0).float() / 255.0
return (latent_out, step_imgs, last_image, stopped_at[0])
# ------------------------------------------------------------------ #
# ユーティリティ(後方互換)
+89 -70
View File
@@ -1,70 +1,89 @@
import torch
import numpy as np
from PIL import Image
import io
import base64
from server import PromptServer
def encode_images_to_base64(images):
"""画像テンソルのバッチをBase64文字列のリストに変換する共通関数"""
encoded_images = []
for img in images:
# Tensor (B, H, W, C) -> Numpy -> PIL
i = 255. * img.cpu().numpy()
pil_img = Image.fromarray(np.uint8(i))
buffered = io.BytesIO()
pil_img.save(buffered, format="JPEG", quality=85)
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
encoded_images.append(f"data:image/jpeg;base64,{img_str}")
return encoded_images
# 1. 再生用ビューアノード
class StepStepPlayer:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"images": ("IMAGE",),
}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "view_images"
CATEGORY = "custom_nodes/viewers"
def view_images(self, images):
encoded = encode_images_to_base64(images)
# フロントエンドの Player 側に送信
PromptServer.instance.send_json("step_player_update", {"images": encoded})
return {"ui": {"images": encoded}}
# 2. 比較用ビューアノード
class StepStepComparer:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"images": ("IMAGE",),
}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "view_images"
CATEGORY = "custom_nodes/viewers"
def view_images(self, images):
encoded = encode_images_to_base64(images)
# フロントエンドの Comparer 側に送信
PromptServer.instance.send_json("step_comparer_update", {"images": encoded})
return {"ui": {"images": encoded}}
# --- ノードの登録設定 ---
NODE_CLASS_MAPPINGS = {
"StepStepPlayer": StepStepPlayer,
"StepStepComparer": StepStepComparer
}
NODE_DISPLAY_NAME_MAPPINGS = {
"StepStepPlayer": "Step-by-Step Player",
"StepStepComparer": "Step-by-Step Comparer"
}
import glob
import os
import uuid
import numpy as np
from PIL import Image
import folder_paths
_SUBFOLDER = "step_by_step"
def save_step_images(images, node_id, kind):
"""
画像テンソル [B,H,W,C] を temp フォルダに JPEG で保存し、フロントエンド用の参照リストを返す。
base64 を ui / WebSocket / 履歴に載せると、ステップ数ぶんの画像が毎回サーバーの履歴に残り
メッセージも巨大になるため、PreviewImage と同様にファイル参照だけを渡す。
同じノードの前回分は削除するので、実行を重ねてもファイルは増えない。
"""
out_dir = os.path.join(folder_paths.get_temp_directory(), _SUBFOLDER)
os.makedirs(out_dir, exist_ok=True)
node_id = "".join(c for c in str(node_id) if c.isalnum() or c in "-_") or "0"
prefix = f"{kind}_{node_id}_"
for old in glob.glob(os.path.join(out_dir, prefix + "*")):
try:
os.remove(old)
except OSError:
pass
run_id = uuid.uuid4().hex[:8]
refs = []
for i, img in enumerate(images):
arr = np.clip(img.cpu().numpy() * 255.0, 0, 255).astype(np.uint8)
if arr.ndim == 3 and arr.shape[-1] == 1:
arr = arr[:, :, 0]
elif arr.ndim == 3 and arr.shape[-1] > 3:
arr = arr[:, :, :3] # アルファは JPEG に保存できない
filename = f"{prefix}{run_id}_{i:04d}.jpg"
Image.fromarray(arr).save(os.path.join(out_dir, filename), format="JPEG", quality=90)
refs.append({"filename": filename, "subfolder": _SUBFOLDER, "type": "temp"})
return refs
# 1. 再生用ビューアノード
class StepStepPlayer:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"images": ("IMAGE",),
},
"hidden": {"unique_id": "UNIQUE_ID"}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "view_images"
CATEGORY = "custom_nodes/viewers"
def view_images(self, images, unique_id=None):
return {"ui": {"step_images": save_step_images(images, unique_id, "player")}}
# 2. 比較用ビューアノード
class StepStepComparer:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"images": ("IMAGE",),
},
"hidden": {"unique_id": "UNIQUE_ID"}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "view_images"
CATEGORY = "custom_nodes/viewers"
def view_images(self, images, unique_id=None):
return {"ui": {"step_images": save_step_images(images, unique_id, "comparer")}}
# --- ノードの登録設定 ---
NODE_CLASS_MAPPINGS = {
"StepStepPlayer": StepStepPlayer,
"StepStepComparer": StepStepComparer
}
NODE_DISPLAY_NAME_MAPPINGS = {
"StepStepPlayer": "Step-by-Step Player",
"StepStepComparer": "Step-by-Step Comparer"
}
+1 -1
View File
@@ -8,7 +8,7 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"StepByStepGridSampler": "Step-by-Step Sampler",
"StepByStepSampler": "Step-by-Step Sampler",
"StepStepPlayer": "Step-by-Step Player",
"StepStepComparer": "Step-by-Step Comparer"
}
+151 -46
View File
@@ -6,23 +6,98 @@ const FONT_STACK = "'Source Sans Pro', 'Helvetica Neue', Arial, 'Hiragino Kaku G
// ★ BASE_STYLE 内に font-family を !important 付きで追加
const BASE_STYLE = `
display:flex;
flex-direction:column;
align-items:center;
padding:10px;
background:#111;
border-radius:8px;
width:100%;
height:100%;
box-sizing:border-box;
overflow:hidden;
display:flex;
flex-direction:column;
align-items:center;
padding:10px;
background:#111;
border-radius:8px;
width:100%;
height:100%;
box-sizing:border-box;
overflow:hidden;
font-family: ${FONT_STACK} !important;
`;
const BLANK_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
const MIN_INTERVAL_MS = 30;
// ---- スライダーを操作しやすくする ----
// ブラウザ標準のスライダーはつまみが小さく、ステップ数が少ないとつかみにくいため、
// つまみ・トラックを大きくし、マウスホイールでも 1 ステップずつ動かせるようにする。
const SLIDER_CSS = `
.sbs-range { -webkit-appearance:none; appearance:none; width:100%; height:24px; margin:2px 0;
background:transparent; cursor:pointer; }
.sbs-range::-webkit-slider-runnable-track { height:8px; background:#444; border-radius:4px; }
.sbs-range::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; box-sizing:border-box;
width:20px; height:20px; margin-top:-6px; border-radius:50%;
background:#4da3ff; border:2px solid #fff; }
.sbs-range::-moz-range-track { height:8px; background:#444; border-radius:4px; }
.sbs-range::-moz-range-thumb { box-sizing:border-box; width:20px; height:20px; border-radius:50%;
background:#4da3ff; border:2px solid #fff; }
.sbs-range:hover::-webkit-slider-thumb { background:#7bbcff; }
.sbs-range:focus-visible { outline:1px solid #4da3ff; outline-offset:2px; }
`;
const ensureSliderStyle = () => {
if (document.getElementById("sbs-slider-style")) return;
const style = document.createElement("style");
style.id = "sbs-slider-style";
style.textContent = SLIDER_CSS;
document.head.appendChild(style);
};
const setupSlider = (slider, onChange) => {
slider.classList.add("sbs-range");
slider.step = 1;
// ComfyUI のフロントエンドは、ホイールを既定でキャンバスのズームに転送する。
// data-capture-wheel を付け、かつ要素にフォーカスがあるときだけ転送されなくなるため、
// マウスを載せた時点でフォーカスし、離れたら外す。
slider.setAttribute("data-capture-wheel", "true");
slider.addEventListener("mouseenter", () => slider.focus({ preventScroll: true }));
slider.addEventListener("mouseleave", () => { if (document.activeElement === slider) slider.blur(); });
const move = (delta) => {
const max = parseInt(slider.max) || 0;
const next = Math.max(0, Math.min(max, (parseInt(slider.value) || 0) + delta));
if (next !== parseInt(slider.value)) {
slider.value = next;
onChange();
}
};
// ホイール: 下=次のステップ / 上=前のステップ
slider.addEventListener("wheel", (ev) => {
ev.preventDefault();
ev.stopPropagation();
move(ev.deltaY > 0 ? 1 : -1);
}, { passive: false });
// キー操作: ComfyUI 側のキー処理に奪われないよう、ここで処理して伝播を止める
slider.addEventListener("keydown", (ev) => {
const max = parseInt(slider.max) || 0;
let handled = true;
if (ev.key === "ArrowLeft" || ev.key === "ArrowDown") move(-1);
else if (ev.key === "ArrowRight" || ev.key === "ArrowUp") move(1);
else if (ev.key === "Home") move(-max);
else if (ev.key === "End") move(max);
else handled = false;
if (handled) { ev.preventDefault(); ev.stopPropagation(); }
});
};
// サーバーが temp フォルダに保存した画像の参照 → /view の URL
const refToUrl = (ref, token) => {
const params = new URLSearchParams({
filename: ref.filename,
subfolder: ref.subfolder ?? "",
type: ref.type ?? "temp",
t: token, // 同じファイル名でも実行ごとに再取得させる
});
return api.apiURL(`/view?${params.toString()}`);
};
app.registerExtension({
name: "Comfy.StepFlowPack",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "StepStepPlayer" || nodeData.name === "StepStepComparer") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
@@ -33,47 +108,47 @@ app.registerExtension({
container.style.cssText = BASE_STYLE;
let imgA, imgB, div, viewPort, imgPlayer;
if (isPlayer) {
imgPlayer = document.createElement("img");
imgPlayer.style.cssText = "width:100%; height:100%; object-fit:contain; background:#000; flex-grow:1; min-height:200px;";
imgPlayer.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
imgPlayer.src = BLANK_GIF;
container.appendChild(imgPlayer);
} else {
viewPort = document.createElement("div");
viewPort.style.cssText = "width:100%; flex-grow:1; min-height:200px; position:relative; overflow:hidden; cursor:col-resize; background:#000;";
imgB = document.createElement("img");
imgB.style.cssText = "width:100%; height:100%; object-fit:contain; position:absolute; top:0; left:0;";
imgA = document.createElement("img");
imgA.style.cssText = "width:100%; height:100%; object-fit:contain; position:absolute; top:0; left:0; clip-path:inset(0 50% 0 0);";
div = document.createElement("div");
div.style.cssText = "position:absolute; top:0; bottom:0; left:50%; width:1px; background:rgba(255,255,255,0.4); z-index:10; pointer-events:none; box-shadow: 0 0 2px rgba(0,0,0,0.3);";
imgA.src = imgB.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
imgA.src = imgB.src = BLANK_GIF;
viewPort.append(imgB, imgA, div);
container.appendChild(viewPort);
}
const controls = document.createElement("div");
controls.style.cssText = "width:100%; flex-shrink:0; padding-top:10px;";
const s1 = document.createElement("input"); s1.type = "range"; s1.style.width = "100%";
s1.min = 0; s1.max = 0; s1.value = 0;
const s2 = isPlayer ? null : document.createElement("input");
const s2 = isPlayer ? null : document.createElement("input");
if (s2) { s2.type = "range"; s2.style.width = "100%"; s2.min = 0; s2.max = 0; s2.value = 0; }
// ★ label にも強制的にフォントを適用
const label = document.createElement("div");
label.style.cssText = `
font-size:12px;
color:#ccc;
text-align:center;
margin:5px 0;
font-weight: 600;
font-size:12px;
color:#ccc;
text-align:center;
margin:5px 0;
font-weight: 600;
font-family: ${FONT_STACK} !important;
`;
label.innerText = "Waiting for data...";
@@ -82,13 +157,21 @@ app.registerExtension({
if (s2) controls.appendChild(s2);
controls.appendChild(label);
let timer = null;
const stopTimer = () => {
if (timer) { clearInterval(timer); timer = null; }
if (this.playBtn) this.playBtn.innerText = "▶ Play";
};
if (isPlayer) {
const speedRow = document.createElement("div");
speedRow.style.cssText = "display:flex; gap:5px; align-items:center; justify-content:center; margin-bottom:5px;";
const speedLabel = document.createElement("span");
speedLabel.style.cssText = `font-size:10px; color:#aaa; font-family:${FONT_STACK} !important;`;
speedLabel.textContent = "ms:";
const speedInput = document.createElement("input");
speedInput.type = "number"; speedInput.value = 500; speedInput.style.width = "60px";
speedRow.innerHTML = `<span style='font-size:10px; color:#aaa; font-family:${FONT_STACK} !important;'>ms:</span>`;
speedRow.appendChild(speedInput);
speedInput.type = "number"; speedInput.value = 500; speedInput.min = MIN_INTERVAL_MS; speedInput.style.width = "60px";
speedRow.append(speedLabel, speedInput);
controls.appendChild(speedRow);
const playBtn = document.createElement("button");
@@ -103,7 +186,7 @@ app.registerExtension({
this.addDOMWidget("view_widget", "view", container);
this.images = [];
let timer = null;
this._preload = [];
const refresh = () => {
if (!this.images || this.images.length === 0) return;
@@ -124,26 +207,34 @@ app.registerExtension({
}
};
ensureSliderStyle();
setupSlider(s1, refresh);
if (s2) setupSlider(s2, refresh);
s1.oninput = refresh;
if (s2) s2.oninput = refresh;
if (isPlayer) {
const intervalMs = () => Math.max(MIN_INTERVAL_MS, parseInt(this.speedInput.value) || 500);
const startTimer = () => {
this.playBtn.innerText = "⏸ Pause";
timer = setInterval(() => {
s1.value = (parseInt(s1.value) + 1) % this.images.length;
refresh();
}, intervalMs());
};
this.playBtn.onclick = () => {
if (timer) { clearInterval(timer); timer = null; this.playBtn.innerText = "▶ Play"; }
else {
if (!this.images.length) return;
this.playBtn.innerText = "⏸ Pause";
timer = setInterval(() => {
s1.value = (parseInt(s1.value) + 1) % this.images.length;
refresh();
}, this.speedInput.value);
}
if (timer) { stopTimer(); }
else if (this.images.length) { startTimer(); }
};
// 再生中に速度を変えたら、新しい速度で再開する
this.speedInput.onchange = () => {
if (timer) { clearInterval(timer); startTimer(); }
};
} else {
const handleMove = (e) => {
const rect = viewPort.getBoundingClientRect();
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
if (!clientX) return;
const clientX = e.clientX ?? e.touches?.[0]?.clientX;
if (clientX === undefined || clientX === null) return;
const p = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
div.style.left = `${p}%`;
imgA.style.clipPath = `inset(0 ${100-p}% 0 0)`;
@@ -152,8 +243,11 @@ app.registerExtension({
viewPort.ontouchmove = handleMove;
}
const handleData = (imgList) => {
this.images = imgList;
const handleData = (refs) => {
const token = Date.now();
this.images = refs.map((r) => refToUrl(r, token));
// 切り替え時のちらつきを避けるため先読みしておく
this._preload = this.images.map((u) => { const im = new Image(); im.src = u; return im; });
s1.max = this.images.length - 1;
if (isPlayer) s1.value = 0;
else {
@@ -164,9 +258,20 @@ app.registerExtension({
refresh();
};
const msgName = isPlayer ? "step_player_update" : "step_comparer_update";
api.addEventListener(msgName, (e) => handleData(e.detail.images));
this.onExecuted = (message) => { if (message?.images) handleData(message.images); };
this.onExecuted = (message) => {
if (Array.isArray(message?.step_images) && message.step_images.length) {
handleData(message.step_images);
}
};
// ノード削除時にタイマーを止める(残ると削除後も動き続ける)
const onRemoved = this.onRemoved;
this.onRemoved = function () {
stopTimer();
this.images = [];
this._preload = [];
return onRemoved?.apply(this, arguments);
};
};
nodeType.prototype.onAdded = function() { this.size = [400, 500]; };
}
+15 -15
View File
@@ -1,16 +1,16 @@
[project]
name = "ComfyUI-StepByStep-Sampler"
description = "ComfyUI StepByStep Sampler is a plugin for ComfyUI that provides a step-by-step sampling method for image generation. It allows users to generate images in a more controlled and iterative manner, making it easier to achieve desired results."
version = "1.0.1"
readme = "README.md"
license = {file = "LICENSE"}
dependencies = []
[project.urls]
Repository = "https://github.com/TakkunRed/ComfyUI-StepByStep-Sampler"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
PublisherId = "takkun"
DisplayName = "ComfyUI-StepByStep-Sampler"
[project]
name = "ComfyUI-StepByStep-Sampler"
description = "ComfyUI StepByStep Sampler is a plugin for ComfyUI that provides a step-by-step sampling method for image generation. It allows users to generate images in a more controlled and iterative manner, making it easier to achieve desired results."
version = "1.0.2"
readme = "README.md"
license = {file = "LICENSE"}
dependencies = []
[project.urls]
Repository = "https://github.com/TakkunRed/ComfyUI-StepByStep-Sampler"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
PublisherId = "takkun"
DisplayName = "ComfyUI-StepByStep-Sampler"
Icon = ""