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:
co-authored by
Claude Sonnet 5
parent
c5888ecbe9
commit
ab9d4036cd
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = ""
|
||||
Reference in New Issue
Block a user