diff --git a/README.md b/README.md
index be5b7eb..d78d18f 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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
\ No newline at end of file
diff --git a/StepByStep_Sampler.py b/StepByStep_Sampler.py
index 81e53c0..b33b32a 100644
--- a/StepByStep_Sampler.py
+++ b/StepByStep_Sampler.py
@@ -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])
# ------------------------------------------------------------------ #
# ユーティリティ(後方互換)
diff --git a/StepByStep_Viewer.py b/StepByStep_Viewer.py
index 6daa760..b42029e 100644
--- a/StepByStep_Viewer.py
+++ b/StepByStep_Viewer.py
@@ -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"
-}
\ No newline at end of file
+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"
+}
diff --git a/__init__.py b/__init__.py
index 126ee33..2aa6fde 100644
--- a/__init__.py
+++ b/__init__.py
@@ -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"
}
diff --git a/js/StepByStep_Viewer.js b/js/StepByStep_Viewer.js
index d5389f2..206079e 100644
--- a/js/StepByStep_Viewer.js
+++ b/js/StepByStep_Viewer.js
@@ -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 = `ms:`;
- 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]; };
}
diff --git a/pyproject.toml b/pyproject.toml
index 717fbed..1c5f2d3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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 = ""
\ No newline at end of file