182 lines
6.7 KiB
Python
182 lines
6.7 KiB
Python
"""
|
|
KSampler標準プレビューは1枚のまま維持し、CueTimerだけへ複数コマのストリップを送る。
|
|
プレビュー窓はKSamplerのステップ進捗に応じて動画時間軸上を後方へスライドする。
|
|
|
|
例(100フレーム / 20ステップ):
|
|
step 1 -> 0-7
|
|
step 10 -> 50-57
|
|
step 20 -> 92-99
|
|
|
|
KSampler側には、その窓の最後の1コマだけを返す。
|
|
"""
|
|
|
|
import base64
|
|
import io
|
|
from contextvars import ContextVar
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
|
|
# CueTimerのループアニメーションに含める最大コマ数。
|
|
# 8へ戻す場合は、この値だけを8に変更する。
|
|
MAX_PREVIEW_FRAMES = 16
|
|
_PREVIEW_PROGRESS = ContextVar("slimy_preview_progress", default=None)
|
|
|
|
|
|
def _tensor_to_pil(t: torch.Tensor) -> Image.Image:
|
|
t = t.detach().float().cpu()
|
|
if t.min() < -0.01:
|
|
t = (t + 1.0) / 2.0
|
|
t = torch.clamp(t, 0.0, 1.0)
|
|
arr = (t.numpy() * 255).astype(np.uint8)
|
|
arr = np.moveaxis(arr, 0, 2)
|
|
return Image.fromarray(arr)
|
|
|
|
|
|
def _image_to_data_url(image: Image.Image) -> str:
|
|
buf = io.BytesIO()
|
|
image.save(buf, format="PNG", optimize=False)
|
|
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
|
|
return f"data:image/png;base64,{encoded}"
|
|
|
|
|
|
class _SlidingWindowPreviewer:
|
|
def __init__(self, base_previewer, device, latent_format):
|
|
self.base = base_previewer
|
|
self.device = device
|
|
self.latent_format = latent_format
|
|
self._taesd = None
|
|
|
|
def _get_taesd(self):
|
|
# CueTimer専用にTAESDをGPUへ追加常駐させない。
|
|
# 標準previewerとは別のデコーダを保持すると、実行後も専用GPUメモリが
|
|
# 解放されにくくなるため、ストリップは軽量latent2rgbで生成する。
|
|
return None
|
|
|
|
def _latent2rgb_frame(self, latent_chw):
|
|
factors = getattr(self.latent_format, "latent_rgb_factors", None)
|
|
if factors is None:
|
|
rgb = latent_chw.mean(dim=0, keepdim=True).repeat(3, 1, 1)
|
|
return _tensor_to_pil(rgb)
|
|
factors_t = torch.tensor(
|
|
factors,
|
|
device=latent_chw.device,
|
|
dtype=latent_chw.dtype,
|
|
)
|
|
rgb = torch.einsum("chw,cr->rhw", latent_chw, factors_t)
|
|
return _tensor_to_pil(rgb)
|
|
|
|
@staticmethod
|
|
def _window_start(total_frames: int, window_size: int) -> int:
|
|
max_start = max(0, total_frames - window_size)
|
|
progress = _PREVIEW_PROGRESS.get()
|
|
if not progress:
|
|
return 0
|
|
|
|
step, total_steps = progress
|
|
try:
|
|
step = int(step)
|
|
total_steps = int(total_steps)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
if step <= 0 or total_steps <= 1:
|
|
return 0
|
|
|
|
# callbackのstepは0始まり。表示上のstep番号は step+1。
|
|
# 100f/20stepsなら step10で start=50、最終stepで92へクランプ。
|
|
ratio = min(1.0, max(0.0, (step + 1) / total_steps))
|
|
return min(max_start, round(total_frames * ratio))
|
|
|
|
def _decode_strip(self, x0, start: int, count: int) -> Image.Image:
|
|
frames = []
|
|
# CueTimer用ストリップは追加モデルを使わずlatent2rgbだけで作る。
|
|
# これにより独自TAESDのGPU常駐と、各ステップ16回のデコードを避ける。
|
|
with torch.no_grad():
|
|
for t in range(start, start + count):
|
|
frames.append(self._latent2rgb_frame(x0[0, :, t]))
|
|
|
|
width, height = frames[0].size
|
|
strip = Image.new("RGB", (width * len(frames), height))
|
|
for i, frame in enumerate(frames):
|
|
strip.paste(frame, (i * width, 0))
|
|
return strip
|
|
|
|
@staticmethod
|
|
def _send_cuetimer_preview(strip: Image.Image, frame_count: int, start: int):
|
|
try:
|
|
from server import PromptServer
|
|
|
|
PromptServer.instance.send_sync(
|
|
"slimy_peep_preview",
|
|
{
|
|
"image": _image_to_data_url(strip),
|
|
"frames": frame_count,
|
|
"start": start,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
print(f"[Slimy_CueTimer] CueTimer専用プレビュー送信失敗: {e}")
|
|
|
|
def decode_latent_to_preview_image(self, preview_format, x0):
|
|
if getattr(x0, "ndim", 0) != 5 or x0.shape[2] <= 1:
|
|
return self.base.decode_latent_to_preview_image(preview_format, x0)
|
|
|
|
try:
|
|
total_frames = int(x0.shape[2])
|
|
frame_count = min(MAX_PREVIEW_FRAMES, total_frames)
|
|
start = self._window_start(total_frames, frame_count)
|
|
|
|
# CueTimerには設定したコマ数のプレビュー窓を送る。
|
|
strip = self._decode_strip(x0, start, frame_count)
|
|
self._send_cuetimer_preview(strip, frame_count, start)
|
|
|
|
# KSampler本体には窓の最後の1コマだけを返す。
|
|
last_index = start + frame_count - 1
|
|
selected = x0[:, :, last_index:last_index + 1]
|
|
return self.base.decode_latent_to_preview_image(preview_format, selected)
|
|
except Exception as e:
|
|
print(f"[Slimy_CueTimer] スライド窓プレビュー失敗、標準表示へフォールバック: {e}")
|
|
return self.base.decode_latent_to_preview_image(preview_format, x0)
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self.base, name)
|
|
|
|
|
|
def install():
|
|
try:
|
|
import latent_preview
|
|
except ImportError:
|
|
print("[Slimy_CueTimer] latent_previewが見つからず、preview patchをスキップしました")
|
|
return
|
|
|
|
if getattr(latent_preview, "_slimy_sliding_window_preview_patched", False):
|
|
return
|
|
|
|
original_get_previewer = latent_preview.get_previewer
|
|
original_prepare_callback = latent_preview.prepare_callback
|
|
|
|
def patched_get_previewer(device, latent_format):
|
|
base = original_get_previewer(device, latent_format)
|
|
if base is None:
|
|
return None
|
|
return _SlidingWindowPreviewer(base, device, latent_format)
|
|
|
|
def patched_prepare_callback(model, steps, x0_output_dict=None):
|
|
original_callback = original_prepare_callback(model, steps, x0_output_dict)
|
|
|
|
def callback(step, x0, x, total_steps):
|
|
token = _PREVIEW_PROGRESS.set((step, total_steps))
|
|
try:
|
|
return original_callback(step, x0, x, total_steps)
|
|
finally:
|
|
_PREVIEW_PROGRESS.reset(token)
|
|
|
|
return callback
|
|
|
|
latent_preview.get_previewer = patched_get_previewer
|
|
latent_preview.prepare_callback = patched_prepare_callback
|
|
latent_preview._slimy_sliding_window_preview_patched = True
|
|
print(f"[Slimy_CueTimer] KSampler 1コマ / CueTimer 最大{MAX_PREVIEW_FRAMES}コマ・スライド窓patchを適用しました")
|