Add files via upload
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
import logging
|
||||
import math
|
||||
import types
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import numpy as np
|
||||
import base64
|
||||
import io as pyio
|
||||
from PIL import Image
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
import node_helpers
|
||||
import comfy.model_management
|
||||
import comfy.ldm.modules.attention
|
||||
import json
|
||||
from comfy_api.latest import io, ComfyExtension
|
||||
from typing_extensions import override
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ==============================================================================
|
||||
# 1. CORE UTILS & MATH
|
||||
# ==============================================================================
|
||||
|
||||
def build_temporal_cost(q_token_idx, Lq, Lk, device, dtype, tokens_per_frame):
|
||||
offset = torch.zeros(Lq, Lk, device=device, dtype=dtype)
|
||||
query_frames = torch.arange(Lq, device=device, dtype=torch.long) // tokens_per_frame
|
||||
for seg in q_token_idx:
|
||||
local = seg["local_token_idx"].to(device=device)
|
||||
d = (query_frames.float()[:, None] - seg["midpoint"]).abs()
|
||||
strength = seg.get("strength", 1.0)
|
||||
cost = strength * (torch.relu(d - seg["window"]) ** 2) / (2 * seg["sigma"] ** 2)
|
||||
offset[:, local] = cost.to(offset.dtype)
|
||||
return offset
|
||||
|
||||
def build_temporal_cost_scaled(q_token_idx, Lq, Lk, device, dtype, latent_frames):
|
||||
offset = torch.zeros(Lq, Lk, device=device, dtype=dtype)
|
||||
query_frames = torch.arange(Lq, device=device, dtype=torch.float32) * latent_frames / Lq
|
||||
for seg in q_token_idx:
|
||||
local = seg["local_token_idx"].to(device=device)
|
||||
d = (query_frames[:, None] - seg["midpoint"]).abs()
|
||||
sigma_a = seg.get("sigma_audio", seg["sigma"])
|
||||
window_a = seg.get("window_audio", seg["window"])
|
||||
strength_a = seg.get("strength_audio", 1.0)
|
||||
cost = strength_a * (torch.relu(d - window_a) ** 2) / (2 * sigma_a ** 2)
|
||||
offset[:, local] = cost.to(offset.dtype)
|
||||
return offset
|
||||
|
||||
def create_mask_fn(q_token_idx, fallback_tokens_per_frame, latent_frames):
|
||||
cache = {}
|
||||
max_token_idx = max(int(seg["local_token_idx"].max().item()) for seg in q_token_idx) + 1 if q_token_idx else 0
|
||||
def mask_fn(q, k, transformer_options):
|
||||
Lq, Lk = q.shape[1], k.shape[1]
|
||||
if Lq == Lk: return None
|
||||
cond_or_uncond = transformer_options.get("cond_or_uncond", [])
|
||||
if 1 in cond_or_uncond and 0 not in cond_or_uncond: return None
|
||||
grid_sizes = transformer_options.get("grid_sizes", None)
|
||||
video_tpf = int(grid_sizes[1]) * int(grid_sizes[2]) if grid_sizes is not None else fallback_tokens_per_frame
|
||||
video_lq = latent_frames * video_tpf
|
||||
if Lk == video_lq or Lk < max_token_idx: return None
|
||||
mode = "video" if Lq == video_lq else "scaled"
|
||||
key = (Lq, Lk, mode, q.device)
|
||||
if key not in cache:
|
||||
if mode == "video":
|
||||
cost = build_temporal_cost(q_token_idx, Lq, Lk, q.device, q.dtype, video_tpf)
|
||||
else:
|
||||
cost = build_temporal_cost_scaled(q_token_idx, Lq, Lk, q.device, q.dtype, latent_frames)
|
||||
cache[key] = -cost
|
||||
|
||||
# --- TAMBAHKAN 3 BARIS INI UNTUK MEMUNCULKAN LOG ALA KIJAI ---
|
||||
nonzero = (cost > 0).sum().item()
|
||||
total = Lq * Lk
|
||||
log.info(f"[RikanPromptRelay] Built penalty matrix ({mode}): Lq={Lq}, Lk={Lk}, nonzero={nonzero}/{total}")
|
||||
# -------------------------------------------------------------
|
||||
|
||||
return cache[key].to(q.dtype)
|
||||
return mask_fn
|
||||
|
||||
def build_segments(token_ranges, segment_lengths, epsilon=1e-3):
|
||||
sigma = 1.0 / math.log(1.0 / epsilon) if 0 < epsilon < 1 else 0.1448
|
||||
q_token_idx = []
|
||||
frame_cursor = 0
|
||||
for (tok_start, tok_end), L in zip(token_ranges, segment_lengths):
|
||||
if L <= 0:
|
||||
frame_cursor += L
|
||||
continue
|
||||
midpoint = (2 * frame_cursor + L) // 2
|
||||
base_window = max(L // 2 - 2, 0)
|
||||
q_token_idx.append({
|
||||
"local_token_idx": torch.arange(tok_start, tok_end),
|
||||
"midpoint": midpoint,
|
||||
"window": float(base_window),
|
||||
"sigma": sigma,
|
||||
"strength": 1.0,
|
||||
"window_audio": float(base_window),
|
||||
"sigma_audio": sigma,
|
||||
"strength_audio": 1.0,
|
||||
})
|
||||
frame_cursor += L
|
||||
return q_token_idx
|
||||
|
||||
def get_raw_tokenizer(clip):
|
||||
tokenizer_wrapper = clip.tokenizer
|
||||
for attr_name in dir(tokenizer_wrapper):
|
||||
if attr_name.startswith("_"): continue
|
||||
inner = getattr(tokenizer_wrapper, attr_name, None)
|
||||
if inner is not None and hasattr(inner, "tokenizer"):
|
||||
return inner.tokenizer
|
||||
raise RuntimeError("Could not find raw tokenizer.")
|
||||
|
||||
def map_token_indices(raw_tokenizer, global_prompt, local_prompts):
|
||||
prefixed_locals = [" " + lp for lp in local_prompts]
|
||||
has_eos = getattr(raw_tokenizer, "add_eos", False)
|
||||
eos_adj = 1 if has_eos else 0
|
||||
prev_len = len(raw_tokenizer(global_prompt)["input_ids"]) - eos_adj
|
||||
token_ranges = []
|
||||
built = global_prompt
|
||||
for plp in prefixed_locals:
|
||||
built += plp
|
||||
cur_len = len(raw_tokenizer(built)["input_ids"]) - eos_adj
|
||||
token_ranges.append((prev_len, cur_len))
|
||||
prev_len = cur_len
|
||||
return global_prompt + "".join(prefixed_locals), token_ranges
|
||||
|
||||
def distribute_segment_lengths(num_segments, latent_frames, specified_lengths=None):
|
||||
if specified_lengths:
|
||||
lengths = specified_lengths
|
||||
else:
|
||||
step = -(-latent_frames // num_segments)
|
||||
lengths = [step] * num_segments
|
||||
effective, cursor = [], 0
|
||||
for L in lengths:
|
||||
end = min(cursor + L, latent_frames)
|
||||
effective.append(max(end - cursor, 0))
|
||||
cursor = end
|
||||
return effective
|
||||
|
||||
def _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames):
|
||||
if not pixel_lengths: return []
|
||||
total_pixel = sum(pixel_lengths)
|
||||
if total_pixel <= 0: return [1] * len(pixel_lengths)
|
||||
target_total = min(latent_frames, max(1, round(total_pixel / temporal_stride)))
|
||||
if target_total >= latent_frames - 1: target_total = latent_frames
|
||||
exact = [p * target_total / total_pixel for p in pixel_lengths]
|
||||
result = [int(e) for e in exact]
|
||||
diff = target_total - sum(result)
|
||||
if diff > 0:
|
||||
order = sorted(range(len(exact)), key=lambda i: -(exact[i] - int(exact[i])))
|
||||
for k in range(diff): result[order[k % len(order)]] += 1
|
||||
return result
|
||||
|
||||
# ==============================================================================
|
||||
# 2. MODEL PATCHING & LORA GATE HELPERS
|
||||
# ==============================================================================
|
||||
|
||||
def detect_model_type(model):
|
||||
diff_model = model.model.diffusion_model
|
||||
if hasattr(diff_model, "patch_size") and not hasattr(diff_model, "patchifier"):
|
||||
return "wan", tuple(diff_model.patch_size), 4
|
||||
if hasattr(diff_model, "patchifier"):
|
||||
return "ltx", (1, 1, 1), int(diff_model.vae_scale_factors[0])
|
||||
raise ValueError("Unsupported model type.")
|
||||
|
||||
class _CrossAttnPatch:
|
||||
def __init__(self, impl, mask_fn):
|
||||
self.impl, self.mask_fn = impl, mask_fn
|
||||
def __get__(self, obj, objtype=None):
|
||||
def wrapped(self_module, *args, **kwargs):
|
||||
return self.impl(self_module, self.mask_fn, *args, **kwargs)
|
||||
return types.MethodType(wrapped, obj)
|
||||
|
||||
def apply_patches(model_clone, arch, mask_fn):
|
||||
diffusion_model = model_clone.get_model_object("diffusion_model")
|
||||
if arch == "wan":
|
||||
from comfy.ldm.wan.model import WanI2VCrossAttention
|
||||
for idx, block in enumerate(diffusion_model.blocks):
|
||||
impl = _wan_i2v_forward if isinstance(block.cross_attn, WanI2VCrossAttention) else _wan_t2v_forward
|
||||
model_clone.add_object_patch(f"diffusion_model.blocks.{idx}.cross_attn.forward", _CrossAttnPatch(impl, mask_fn).__get__(block.cross_attn))
|
||||
elif arch == "ltx":
|
||||
for idx, block in enumerate(diffusion_model.transformer_blocks):
|
||||
for attr in ("attn2", "audio_attn2"):
|
||||
module = getattr(block, attr, None)
|
||||
if module: model_clone.add_object_patch(f"diffusion_model.transformer_blocks.{idx}.{attr}.forward", _CrossAttnPatch(_ltx_forward, mask_fn).__get__(module))
|
||||
|
||||
def _wan_t2v_forward(self, mask_fn, x, context, transformer_options={}, **kwargs):
|
||||
q, k, v = self.norm_q(self.q(x)), self.norm_k(self.k(context)), self.v(context)
|
||||
mask = mask_fn(q, k, transformer_options)
|
||||
return self.o(comfy.ldm.modules.attention.attention_pytorch(q, k, v, self.num_heads, mask=mask, transformer_options=transformer_options) if mask is not None else comfy.ldm.modules.attention.optimized_attention(q, k, v, self.num_heads, transformer_options=transformer_options))
|
||||
|
||||
def _wan_i2v_forward(self, mask_fn, x, context, context_img_len, transformer_options={}, **kwargs):
|
||||
q = self.norm_q(self.q(x))
|
||||
img_x = comfy.ldm.modules.attention.optimized_attention(q, self.norm_k_img(self.k_img(context[:, :context_img_len])), self.v_img(context[:, :context_img_len]), self.num_heads, transformer_options=transformer_options)
|
||||
mask = mask_fn(q, self.norm_k(self.k(context[:, context_img_len:])), transformer_options)
|
||||
attn_txt = comfy.ldm.modules.attention.attention_pytorch(q, self.norm_k(self.k(context[:, context_img_len:])), self.v(context[:, context_img_len:]), self.num_heads, mask=mask, transformer_options=transformer_options) if mask is not None else comfy.ldm.modules.attention.optimized_attention(q, self.norm_k(self.k(context[:, context_img_len:])), self.v(context[:, context_img_len:]), self.num_heads, transformer_options=transformer_options)
|
||||
return self.o(attn_txt + img_x)
|
||||
|
||||
def _ltx_forward(self, mask_fn, x, context=None, mask=None, pe=None, k_pe=None, transformer_options={}):
|
||||
from comfy.ldm.lightricks.model import apply_rotary_emb
|
||||
is_self = context is None
|
||||
c = x if is_self else context
|
||||
q, k, v = self.q_norm(self.to_q(x)), self.k_norm(self.to_k(c)), self.to_v(c)
|
||||
if pe is not None: q, k = apply_rotary_emb(q, pe), apply_rotary_emb(k, pe if k_pe is None else k_pe)
|
||||
if not is_self:
|
||||
tm = mask_fn(q, k, transformer_options)
|
||||
mask = tm if mask is None else mask + tm
|
||||
out = comfy.ldm.modules.attention.optimized_attention(q, k, v, self.heads, mask=mask, transformer_options=transformer_options)
|
||||
if self.to_gate_logits: out = out * (2.0 * torch.sigmoid(self.to_gate_logits(x))).unsqueeze(-1)
|
||||
return self.to_out(out)
|
||||
|
||||
# ── LORA GATE INTERNAL CLASSES ───────────────────────────────────────────────
|
||||
|
||||
class _TemporalGatedLoRA:
|
||||
def __init__(self, original, lora_down, lora_up, scale, strength, gate_weights, tokens_per_frame):
|
||||
self.original = original
|
||||
self._lora_down = lora_down.cpu()
|
||||
self._lora_up = lora_up.cpu()
|
||||
self.scale = scale
|
||||
self.strength = strength
|
||||
self._gate_weights = gate_weights.cpu()
|
||||
self.tokens_per_frame = tokens_per_frame
|
||||
|
||||
def forward(self, x, *args, **kwargs):
|
||||
base = self.original(x, *args, **kwargs)
|
||||
lora_down = self._lora_down.to(device=x.device, dtype=x.dtype)
|
||||
lora_up = self._lora_up.to(device=x.device, dtype=x.dtype)
|
||||
|
||||
delta = (x @ lora_down.T) @ lora_up.T
|
||||
delta = delta * (self.scale * self.strength)
|
||||
|
||||
if x.dim() < 2: return base + delta
|
||||
|
||||
seq_len = x.shape[-2]
|
||||
n_frames = len(self._gate_weights)
|
||||
n_video_tokens = n_frames * self.tokens_per_frame
|
||||
|
||||
if seq_len >= n_video_tokens and n_video_tokens > 0:
|
||||
gate_weights = self._gate_weights.to(device=x.device, dtype=x.dtype)
|
||||
frame_idx = torch.arange(n_video_tokens, device=x.device) // self.tokens_per_frame
|
||||
frame_idx = frame_idx.clamp(0, n_frames - 1)
|
||||
video_gates = gate_weights[frame_idx]
|
||||
|
||||
if seq_len > n_video_tokens:
|
||||
extra = torch.zeros(seq_len - n_video_tokens, device=x.device, dtype=x.dtype)
|
||||
token_gates = torch.cat([video_gates, extra])
|
||||
else:
|
||||
token_gates = video_gates
|
||||
|
||||
view_shape = (1,) * (x.dim() - 2) + (seq_len, 1)
|
||||
delta = delta * token_gates.view(view_shape)
|
||||
|
||||
return base + delta
|
||||
|
||||
def _build_gate_weights(seg, latent_frames):
|
||||
midpoint, window, sigma = float(seg["midpoint"]), float(seg["window"]), float(seg["sigma"])
|
||||
weights = []
|
||||
for frame in range(latent_frames):
|
||||
d = abs(frame - midpoint)
|
||||
cost = (max(d - window, 0.0) ** 2) / (2.0 * sigma ** 2)
|
||||
weights.append(math.exp(-cost))
|
||||
return torch.tensor(weights, dtype=torch.float32)
|
||||
|
||||
def _get_module(diffusion_model, local_path):
|
||||
m = diffusion_model
|
||||
for part in local_path.split("."): m = getattr(m, part)
|
||||
return m
|
||||
|
||||
def _apply_gated_lora(model_clone, lora_path, segment, strength, tokens_per_frame, latent_frames):
|
||||
try:
|
||||
try: import comfy.loras as _lora_mod
|
||||
except ImportError: import comfy.lora as _lora_mod
|
||||
except ImportError:
|
||||
log.error("[Rikan MultiLoraGate] comfy.loras is not available.")
|
||||
return
|
||||
|
||||
lora_file = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
key_map = {}
|
||||
try:
|
||||
_lora_mod.model_lora_keys_unet(model_clone.model, key_map)
|
||||
except Exception as e:
|
||||
log.error("[Rikan MultiLoraGate] Failed to create LoRA key map: %s", e)
|
||||
return
|
||||
|
||||
try:
|
||||
patches = _lora_mod.load_lora(lora_file, key_map)
|
||||
except Exception as e:
|
||||
log.error("[Rikan MultiLoraGate] Failed to parse LoRA file: %s", e)
|
||||
return
|
||||
|
||||
gate_weights = _build_gate_weights(segment, latent_frames)
|
||||
diffusion_model = model_clone.get_model_object("diffusion_model")
|
||||
|
||||
# Variabel logging untuk debug gaya Boyo
|
||||
applied = 0
|
||||
skipped_not_weight = 0
|
||||
skipped_not_diffusion = 0
|
||||
skipped_bad_data = 0
|
||||
skipped_not_linear = 0
|
||||
_logged_patch_sample = False
|
||||
|
||||
for param_key, patch_data in patches.items():
|
||||
if not _logged_patch_sample:
|
||||
log.info("[Rikan MultiLoraGate] patch_data sample — key=%s attrs=%s", param_key, [a for a in dir(patch_data) if not a.startswith("_")])
|
||||
log.info("[Rikan MultiLoraGate] patch_data.weights=%s multiplier=%s", repr(patch_data.weights), repr(patch_data.multiplier))
|
||||
_logged_patch_sample = True
|
||||
|
||||
if not param_key.endswith(".weight"):
|
||||
skipped_not_weight += 1
|
||||
continue
|
||||
|
||||
module_path = param_key[:-7]
|
||||
if not module_path.startswith("diffusion_model."):
|
||||
skipped_not_diffusion += 1
|
||||
continue
|
||||
|
||||
local_path = module_path[len("diffusion_model."):]
|
||||
|
||||
try:
|
||||
lora_up = patch_data.weights[0]
|
||||
lora_down = patch_data.weights[1]
|
||||
alpha = patch_data.multiplier
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
skipped_bad_data += 1
|
||||
continue
|
||||
|
||||
if lora_up is None or lora_down is None:
|
||||
skipped_bad_data += 1
|
||||
continue
|
||||
|
||||
if lora_down.dim() != 2 or lora_up.dim() != 2:
|
||||
skipped_not_linear += 1
|
||||
continue
|
||||
|
||||
rank = lora_down.shape[0]
|
||||
scale = float(alpha) / rank if alpha is not None else 1.0
|
||||
|
||||
target = model_clone.object_patches.get(module_path)
|
||||
if target is None:
|
||||
try:
|
||||
target = _get_module(diffusion_model, local_path)
|
||||
except AttributeError:
|
||||
skipped_not_diffusion += 1
|
||||
continue
|
||||
|
||||
wrapped = _TemporalGatedLoRA(target.forward, lora_down, lora_up, scale, strength, gate_weights, tokens_per_frame)
|
||||
model_clone.add_object_patch(module_path + ".forward", wrapped.forward)
|
||||
applied += 1
|
||||
|
||||
# Print log debug ala Boyo!
|
||||
log.info(
|
||||
"[Rikan MultiLoraGate] Segment midpoint=%.1f window=%.1f — applied %d, not_weight=%d, not_diffusion=%d, bad_data=%d, not_linear=%d",
|
||||
float(segment["midpoint"]), float(segment["window"]), applied,
|
||||
skipped_not_weight, skipped_not_diffusion, skipped_bad_data, skipped_not_linear,
|
||||
)
|
||||
|
||||
# ==============================================================================
|
||||
# 3. NODES
|
||||
# ==============================================================================
|
||||
|
||||
class RikanPromptRelayEncodeTimeline(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="RikanPromptRelayEncodeTimeline",
|
||||
display_name="Rikan Prompt Relay Encode (Timeline)",
|
||||
category="Rikannodes",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Clip.Input("clip"),
|
||||
io.Latent.Input("latent"),
|
||||
io.String.Input("global_prompt", multiline=True, default=""),
|
||||
io.Int.Input("max_frames", default=129, min=1, max=10000),
|
||||
io.String.Input("timeline_data", default=""),
|
||||
io.String.Input("local_prompts", multiline=True, default=""),
|
||||
io.String.Input("segment_lengths", default=""),
|
||||
io.Float.Input("epsilon", default=1e-3, min=1e-6, max=0.99, step=1e-4),
|
||||
io.Float.Input("fps", default=24.0, min=0.1, max=240.0, optional=True),
|
||||
io.Float.Input("seconds", default=5.375, min=0.1, max=1000.0, step=0.1, optional=True),
|
||||
io.Combo.Input("time_units", options=["frames", "seconds"], default="frames", optional=True),
|
||||
],
|
||||
outputs=[
|
||||
io.Model.Output(display_name="model"),
|
||||
io.Conditioning.Output(display_name="positive"),
|
||||
io.Float.Output(display_name="fps_out"),
|
||||
io.Int.Output(display_name="max_frames_out"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, model, clip, latent, global_prompt, max_frames, timeline_data, local_prompts, segment_lengths, epsilon, fps=24.0, seconds=5.375, time_units="frames") -> io.NodeOutput:
|
||||
locals_list = [p.strip() for p in local_prompts.split("|") if p.strip()]
|
||||
if not locals_list: raise ValueError("At least one local prompt is required.")
|
||||
|
||||
arch, patch_size, temporal_stride = detect_model_type(model)
|
||||
samples = latent["samples"]
|
||||
latent_frames = samples.shape[2]
|
||||
tokens_per_frame = (samples.shape[3] // patch_size[1]) * (samples.shape[4] // patch_size[2])
|
||||
|
||||
parsed_lengths = None
|
||||
if segment_lengths.strip():
|
||||
pixel_lengths = [int(x.strip()) for x in segment_lengths.split(",") if x.strip()]
|
||||
parsed_lengths = _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames)
|
||||
|
||||
full_prompt, token_ranges = map_token_indices(get_raw_tokenizer(clip), global_prompt, locals_list)
|
||||
|
||||
# Log Token Info (Ala Boyo)
|
||||
log.info("[RikanPromptRelay] Global: tokens [0:%d] (%d tokens)", token_ranges[0][0], token_ranges[0][0])
|
||||
for i, (s, e) in enumerate(token_ranges):
|
||||
log.info("[RikanPromptRelay] Segment %d: tokens [%d:%d] (%d tokens)", i, s, e, e - s)
|
||||
|
||||
conditioning = clip.encode_from_tokens_scheduled(clip.tokenize(full_prompt))
|
||||
effective_lengths = distribute_segment_lengths(len(locals_list), latent_frames, parsed_lengths)
|
||||
|
||||
log.info(
|
||||
"[RikanPromptRelay] Latent: %d frames, %d tokens/frame, segments: %s",
|
||||
latent_frames, tokens_per_frame, effective_lengths,
|
||||
)
|
||||
|
||||
q_token_idx = build_segments(token_ranges, effective_lengths, epsilon)
|
||||
mask_fn = create_mask_fn(q_token_idx, tokens_per_frame, latent_frames)
|
||||
|
||||
patched = model.clone()
|
||||
apply_patches(patched, arch, mask_fn)
|
||||
|
||||
for key in ["rikan_pr_segments", "pr_segments"]: patched.model_options[key] = q_token_idx
|
||||
for key in ["rikan_pr_latent_frames", "pr_latent_frames"]: patched.model_options[key] = latent_frames
|
||||
for key in ["rikan_pr_tokens_per_frame", "pr_tokens_per_frame"]: patched.model_options[key] = tokens_per_frame
|
||||
|
||||
print(f"\n[Timeline] SUCCESS: Injected {len(q_token_idx)} segments.\n")
|
||||
return io.NodeOutput(patched, conditioning, float(fps), int(max_frames))
|
||||
|
||||
# ── NEW: MULTI LORA GATE NODE ───────────────────────────────────
|
||||
|
||||
class RikanPromptRelayMultiLoraGate(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="RikanPromptRelayMultiLoraGate",
|
||||
display_name="Rikan Prompt Relay Multi LoRA Gate",
|
||||
category="Rikannodes",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.String.Input("lora_data", default="[]", multiline=True),
|
||||
],
|
||||
outputs=[io.Model.Output(display_name="model")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, model, lora_data) -> io.NodeOutput:
|
||||
segments = model.model_options.get("rikan_pr_segments")
|
||||
latent_frames = model.model_options.get("rikan_pr_latent_frames")
|
||||
tokens_per_frame = model.model_options.get("rikan_pr_tokens_per_frame")
|
||||
|
||||
if segments is None:
|
||||
log.warning("[Rikan MultiLoraGate] Metadata Prompt Relay tidak ditemukan. Pastikan terhubung ke node Timeline.")
|
||||
return io.NodeOutput(model)
|
||||
|
||||
try:
|
||||
loras_to_apply = json.loads(lora_data)
|
||||
except Exception as e:
|
||||
log.error(f"[Rikan MultiLoraGate] Gagal memproses data JSON: {e}")
|
||||
return io.NodeOutput(model)
|
||||
|
||||
patched = model.clone()
|
||||
total_segments = len(segments)
|
||||
|
||||
for lora in loras_to_apply:
|
||||
if not lora.get("enable", False) or lora.get("name") == "None":
|
||||
continue
|
||||
|
||||
lora_name = lora.get("name")
|
||||
seg_idx = int(lora.get("segment", 0))
|
||||
strength = float(lora.get("modelStr", 1.0))
|
||||
|
||||
# Pengecekan ketat dengan log deskriptif
|
||||
if seg_idx < 0 or seg_idx >= total_segments:
|
||||
log.warning(f"[Rikan MultiLoraGate] SKIP: LoRA '{lora_name}' mencoba memakai Segmen {seg_idx}, "
|
||||
f"tapi Timeline hanya memiliki {total_segments} segmen (Tersedia indeks 0 sampai {total_segments-1}).")
|
||||
continue
|
||||
|
||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
||||
if lora_path is None:
|
||||
log.warning(f"[Rikan MultiLoraGate] File LoRA tidak ditemukan: {lora_name}")
|
||||
continue
|
||||
|
||||
# Fungsi apply ini sekarang akan menge-print log debug ala Boyo
|
||||
_apply_gated_lora(patched, lora_path, segments[seg_idx], strength, tokens_per_frame, latent_frames)
|
||||
|
||||
# Teruskan metadata agar multi-lora tetap sinkron
|
||||
patched.model_options["rikan_pr_segments"] = segments
|
||||
patched.model_options["rikan_pr_latent_frames"] = latent_frames
|
||||
patched.model_options["rikan_pr_tokens_per_frame"] = tokens_per_frame
|
||||
|
||||
return io.NodeOutput(patched)
|
||||
|
||||
|
||||
class RikanI2VPainterTiledVAE(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="rikan-i2vpainter-tiled-vae",
|
||||
display_name="Rikan I2V Painter (Tiled VAE)",
|
||||
category="Rikannodes",
|
||||
inputs=[
|
||||
io.Conditioning.Input("positive"),
|
||||
io.Conditioning.Input("negative"),
|
||||
io.Vae.Input("vae"),
|
||||
io.Int.Input("width", default=832, min=16, max=4096, step=16),
|
||||
io.Int.Input("height", default=480, min=16, max=4096, step=16),
|
||||
io.Int.Input("length", default=81, min=1, max=4096, step=4),
|
||||
io.Int.Input("batch_size", default=1, min=1, max=4096),
|
||||
io.Int.Input("tile_size", default=512, min=64, max=4096, step=64),
|
||||
io.Int.Input("overlap", default=64, min=0, max=4096, step=32),
|
||||
io.Int.Input("temporal_size", default=64, min=8, max=4096, step=4),
|
||||
io.Int.Input("temporal_overlap", default=24, min=4, max=4096, step=4),
|
||||
io.Float.Input("motion_amplitude", default=1.3, min=1.0, max=2.0, step=0.05),
|
||||
io.Boolean.Input("color_protect", default=True),
|
||||
io.Float.Input("correct_strength", default=0.01, min=0.0, max=0.3, step=0.01),
|
||||
io.ClipVisionOutput.Input("clip_vision", optional=True),
|
||||
io.Image.Input("start_image", optional=True),
|
||||
],
|
||||
outputs=[
|
||||
io.Conditioning.Output(display_name="high_positive"),
|
||||
io.Conditioning.Output(display_name="high_negative"),
|
||||
io.Conditioning.Output(display_name="low_positive"),
|
||||
io.Conditioning.Output(display_name="low_negative"),
|
||||
io.Latent.Output(display_name="latent"),
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, positive, negative, vae, width, height, length, batch_size,
|
||||
tile_size=512, overlap=64, temporal_size=64, temporal_overlap=8,
|
||||
motion_amplitude=1.3, color_protect=True, correct_strength=0.01,
|
||||
start_image=None, clip_vision=None) -> io.NodeOutput:
|
||||
|
||||
latent = torch.zeros([batch_size, 16, ((length - 1) // 4) + 1, height // 8, width // 8],
|
||||
device=comfy.model_management.intermediate_device())
|
||||
|
||||
positive_original, negative_original = positive, negative
|
||||
|
||||
if start_image is not None:
|
||||
start_image = start_image[:1]
|
||||
start_image = comfy.utils.common_upscale(start_image.movedim(-1, 1), width, height, "bilinear", "center").movedim(1, -1)
|
||||
image = torch.ones((length, height, width, 3), device=start_image.device, dtype=start_image.dtype) * 0.5
|
||||
image[0] = start_image[0]
|
||||
|
||||
concat_latent_image = vae.encode_tiled(image, tile_x=tile_size, tile_y=tile_size, overlap=overlap, tile_t=temporal_size, overlap_t=temporal_overlap)
|
||||
mask = torch.ones((1, 1, latent.shape[2], concat_latent_image.shape[-2], concat_latent_image.shape[-1]), device=start_image.device)
|
||||
mask[:, :, 0] = 0.0
|
||||
|
||||
concat_latent_image_original = concat_latent_image.clone()
|
||||
|
||||
if motion_amplitude > 1.0:
|
||||
base_latent, gray_latent = concat_latent_image[:, :, 0:1], concat_latent_image[:, :, 1:]
|
||||
diff = gray_latent - base_latent
|
||||
scaled_latent = base_latent + (diff - diff.mean(dim=(1, 3, 4), keepdim=True)) * motion_amplitude + diff.mean(dim=(1, 3, 4), keepdim=True)
|
||||
concat_latent_image = torch.clamp(torch.cat([base_latent, scaled_latent], dim=2), -6, 6)
|
||||
|
||||
positive = node_helpers.conditioning_set_values(positive, {"concat_latent_image": concat_latent_image, "concat_mask": mask})
|
||||
negative = node_helpers.conditioning_set_values(negative, {"concat_latent_image": concat_latent_image, "concat_mask": mask})
|
||||
|
||||
ref_latent = vae.encode_tiled(start_image[:,:,:,:3], tile_x=tile_size, tile_y=tile_size, overlap=overlap, tile_t=temporal_size, overlap_t=temporal_overlap)
|
||||
positive = node_helpers.conditioning_set_values(positive, {"reference_latents": [ref_latent]}, append=True)
|
||||
negative = node_helpers.conditioning_set_values(negative, {"reference_latents": [torch.zeros_like(ref_latent)]}, append=True)
|
||||
|
||||
out_latent = {"samples": latent}
|
||||
return io.NodeOutput(positive, negative, positive_original, negative_original, out_latent)
|
||||
|
||||
|
||||
class RikanHiddenBase64ImageLoader(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="RikanHiddenBase64ImageLoader",
|
||||
display_name="Rikan Hidden Base64 Image Loader",
|
||||
category="Rikannodes",
|
||||
inputs=[io.String.Input("base64_data", multiline=True, default="")],
|
||||
outputs=[io.Image.Output(display_name="image"), io.String.Output(display_name="raw_base64")]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, base64_data) -> io.NodeOutput:
|
||||
if not base64_data: return io.NodeOutput(torch.zeros((1, 64, 64, 3)), "")
|
||||
try:
|
||||
if "," in base64_data: base64_data = base64_data.split(",")[1]
|
||||
img = Image.open(pyio.BytesIO(base64.b64decode(base64_data))).convert("RGB")
|
||||
img = torch.from_numpy(np.array(img).astype(np.float32) / 255.0)[None,]
|
||||
return io.NodeOutput(img, base64_data)
|
||||
except: return io.NodeOutput(torch.zeros((1, 64, 64, 3)), "")
|
||||
|
||||
# ==============================================================================
|
||||
# MAPPINGS & REGISTRATION
|
||||
# ==============================================================================
|
||||
|
||||
class RikannodesExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||
return [
|
||||
RikanPromptRelayEncodeTimeline,
|
||||
RikanPromptRelayMultiLoraGate,
|
||||
RikanI2VPainterTiledVAE,
|
||||
RikanHiddenBase64ImageLoader
|
||||
]
|
||||
|
||||
async def comfy_entrypoint() -> RikannodesExtension:
|
||||
return RikannodesExtension()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"RikanPromptRelayEncodeTimeline": RikanPromptRelayEncodeTimeline,
|
||||
"RikanPromptRelayMultiLoraGate": RikanPromptRelayMultiLoraGate,
|
||||
"rikan-i2vpainter-tiled-vae": RikanI2VPainterTiledVAE,
|
||||
"RikanHiddenBase64ImageLoader": RikanHiddenBase64ImageLoader,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"RikanPromptRelayEncodeTimeline": "Rikan Prompt Relay Encode (Timeline)",
|
||||
"RikanPromptRelayMultiLoraGate": "Rikan Prompt Relay Multi LoRA Gate",
|
||||
"rikan-i2vpainter-tiled-vae": "Rikan I2V Painter (Tiled VAE)",
|
||||
"RikanHiddenBase64ImageLoader": "Rikan Hidden Base64 Image Loader",
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.RikanHiddenBase64ImageLoader",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "RikanHiddenBase64ImageLoader") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
this.show_custom_image = false;
|
||||
this.custom_image_element = null;
|
||||
|
||||
const base64Widget = this.widgets.find((w) => w.name === "base64_data");
|
||||
if (base64Widget) {
|
||||
// Memberikan ruang 85px di LiteGraph (60px untuk text area + 25px untuk margin bawah)
|
||||
base64Widget.computeSize = function() {
|
||||
return [this.width || 200, 85];
|
||||
};
|
||||
|
||||
// Tinggi elemen fisik tetap 60px agar rapi
|
||||
if (base64Widget.inputEl) {
|
||||
base64Widget.inputEl.style.setProperty("height", "60px", "important");
|
||||
base64Widget.inputEl.style.setProperty("max-height", "60px", "important");
|
||||
base64Widget.inputEl.style.setProperty("min-height", "60px", "important");
|
||||
base64Widget.inputEl.style.setProperty("resize", "none", "important");
|
||||
base64Widget.inputEl.style.overflowY = "scroll";
|
||||
base64Widget.inputEl.style.overflowX = "hidden";
|
||||
base64Widget.inputEl.style.wordBreak = "break-all";
|
||||
}
|
||||
}
|
||||
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const dataUri = event.target.result;
|
||||
const rawBase64 = dataUri.split(',')[1];
|
||||
|
||||
if (base64Widget) {
|
||||
base64Widget.value = rawBase64;
|
||||
}
|
||||
|
||||
this.custom_image_element = new Image();
|
||||
this.custom_image_element.src = dataUri;
|
||||
this.custom_image_element.onload = () => {
|
||||
this.size[1] = this.computeSize()[1];
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
this.addWidget("button", "Upload Image", "upload", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.size[1] = this.computeSize()[1];
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (base64Widget && base64Widget.value) {
|
||||
this.custom_image_element = new Image();
|
||||
this.custom_image_element.src = "data:image/png;base64," + base64Widget.value;
|
||||
this.custom_image_element.onload = () => {
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
if (getExtraMenuOptions) {
|
||||
getExtraMenuOptions.apply(this, arguments);
|
||||
}
|
||||
|
||||
options.unshift({
|
||||
content: this.show_custom_image ? "Hide Image" : "Show Image",
|
||||
callback: () => {
|
||||
this.show_custom_image = !this.show_custom_image;
|
||||
|
||||
if (!this.show_custom_image) {
|
||||
this.size[1] = this.computeSize()[1];
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function (ctx) {
|
||||
if (onDrawForeground) {
|
||||
onDrawForeground.apply(this, arguments);
|
||||
}
|
||||
|
||||
if (this.show_custom_image && this.custom_image_element) {
|
||||
const padding = 10;
|
||||
const w = this.size[0] - padding * 2;
|
||||
|
||||
let y = 30;
|
||||
|
||||
const btnWidget = this.widgets ? this.widgets.find(w => w.name === "upload" || w.type === "button") : null;
|
||||
if (btnWidget && btnWidget.last_y) {
|
||||
y = btnWidget.last_y + 30;
|
||||
} else if (this.widgets && this.widgets.length > 0) {
|
||||
y = this.widgets[this.widgets.length - 1].last_y + 30;
|
||||
}
|
||||
|
||||
const imgW = this.custom_image_element.width;
|
||||
const imgH = this.custom_image_element.height;
|
||||
|
||||
const scale = w / imgW;
|
||||
const drawW = w;
|
||||
const drawH = imgH * scale;
|
||||
|
||||
ctx.drawImage(this.custom_image_element, padding, y, drawW, drawH);
|
||||
|
||||
const requiredHeight = y + drawH + padding;
|
||||
if (this.size[1] < requiredHeight) {
|
||||
this.size[1] = requiredHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "prompt_relay.MultiLoraGateNative",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "RikanPromptRelayMultiLoraGate") {
|
||||
|
||||
const syncData = (node) => {
|
||||
if (!node || !node.widgets) return;
|
||||
const dataWidget = node.widgets.find(w => w.name === "lora_data");
|
||||
if (!dataWidget) return;
|
||||
|
||||
const loras = [];
|
||||
// Hanya memproses widget lora yang benar-benar eksis
|
||||
const loraWidgets = node.widgets.filter(w => w.name && w.name.startsWith("lora_"));
|
||||
|
||||
loraWidgets.forEach(lw => {
|
||||
const idx = lw.name.split("_")[1];
|
||||
const wToggle = node.widgets.find(w => w.name === `enable_${idx}`);
|
||||
const wSeg = node.widgets.find(w => w.name === `segment_${idx}`);
|
||||
const wMod = node.widgets.find(w => w.name === `modelStr_${idx}`);
|
||||
|
||||
if (wToggle && wSeg && wMod) {
|
||||
loras.push({
|
||||
enable: wToggle.value,
|
||||
name: lw.value,
|
||||
segment: parseInt(wSeg.value) || 0,
|
||||
modelStr: parseFloat(wMod.value) || 0.0
|
||||
});
|
||||
}
|
||||
});
|
||||
dataWidget.value = JSON.stringify(loras);
|
||||
};
|
||||
|
||||
const addLoraRow = function(node, index, loraName = "None") {
|
||||
const getLoraList = () => {
|
||||
const loraLoader = LiteGraph.registered_node_types["LoraLoader"];
|
||||
if (loraLoader && loraLoader.nodeData && loraLoader.nodeData.input && loraLoader.nodeData.input.required && loraLoader.nodeData.input.required.lora_name) {
|
||||
return ["None"].concat(loraLoader.nodeData.input.required.lora_name[0] || []);
|
||||
}
|
||||
return ["None"];
|
||||
};
|
||||
|
||||
const wToggle = node.addWidget("toggle", `enable_${index}`, true, () => syncData(node));
|
||||
const wLora = node.addWidget("combo", `lora_${index}`, loraName, () => syncData(node), { values: getLoraList() });
|
||||
|
||||
// Segment default SELALU 0, dengan step 10 agar klik tombol panah bernilai +1
|
||||
const wSeg = node.addWidget("number", `segment_${index}`, 0, () => syncData(node), { min: 0, max: 99, step: 10, precision: 0 });
|
||||
// Strength default SELALU 0.80, dengan step 0.1 agar klik tombol panah bernilai +0.01
|
||||
const wMod = node.addWidget("number", `modelStr_${index}`, 0.80, () => syncData(node), { min: -10.0, max: 10.0, step: 0.1, precision: 2 });
|
||||
|
||||
if (node.btnAddLora && node.btnRemoveLora) {
|
||||
const idx2 = node.widgets.indexOf(node.btnAddLora);
|
||||
if (idx2 !== -1) node.widgets.splice(idx2, 1);
|
||||
|
||||
const idx3 = node.widgets.indexOf(node.btnRemoveLora);
|
||||
if (idx3 !== -1) node.widgets.splice(idx3, 1);
|
||||
|
||||
node.widgets.push(node.btnAddLora);
|
||||
node.widgets.push(node.btnRemoveLora);
|
||||
}
|
||||
syncData(node);
|
||||
};
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
this.serialize_widgets = true;
|
||||
this.loraCount = 0;
|
||||
|
||||
const wData = this.widgets.find(w => w.name === "lora_data");
|
||||
if (wData) {
|
||||
wData.type = "hidden";
|
||||
wData.hidden = true;
|
||||
wData.computeSize = () => [0, -4];
|
||||
}
|
||||
|
||||
this.btnToggleAll = this.addWidget("button", "🔄 Toggle All", "toggle_all", () => {
|
||||
let allOn = true;
|
||||
for (const w of this.widgets) {
|
||||
if (w.name && w.name.startsWith("enable_") && w.value === false) {
|
||||
allOn = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const targetState = !allOn;
|
||||
for (const w of this.widgets) {
|
||||
if (w.name && w.name.startsWith("enable_")) {
|
||||
w.value = targetState;
|
||||
}
|
||||
}
|
||||
syncData(this);
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
|
||||
this.btnAddLora = this.addWidget("button", "➕ Add LoRA", "add_lora", (value, widget, node, pos, event) => {
|
||||
const loraLoader = LiteGraph.registered_node_types["LoraLoader"];
|
||||
let loras = ["None"];
|
||||
if (loraLoader && loraLoader.nodeData && loraLoader.nodeData.input && loraLoader.nodeData.input.required && loraLoader.nodeData.input.required.lora_name) {
|
||||
loras = loraLoader.nodeData.input.required.lora_name[0].filter(l => l !== "None");
|
||||
}
|
||||
|
||||
const menuItems = loras.map(loraName => ({
|
||||
content: loraName,
|
||||
callback: () => {
|
||||
this.loraCount++;
|
||||
addLoraRow(this, this.loraCount, loraName);
|
||||
this.computeSize();
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}));
|
||||
|
||||
if (menuItems.length === 0) {
|
||||
menuItems.push({ content: "No LoRAs found!", callback: () => {} });
|
||||
}
|
||||
|
||||
const e = event || app.canvas.last_mouse_event;
|
||||
new LiteGraph.ContextMenu(menuItems, { event: e, title: "Choose a LoRA" });
|
||||
});
|
||||
|
||||
this.btnRemoveLora = this.addWidget("button", "➖ Remove Last LoRA", "remove_lora", () => {
|
||||
if (this.loraCount > 0) {
|
||||
const suffix = `_${this.loraCount}`;
|
||||
for (let i = this.widgets.length - 1; i >= 0; i--) {
|
||||
if (this.widgets[i].name && this.widgets[i].name.endsWith(suffix)) {
|
||||
this.widgets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
this.loraCount--;
|
||||
syncData(this);
|
||||
this.computeSize();
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
});
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function (info) {
|
||||
if (info && info.widgets_values) {
|
||||
this.loraCount = 0;
|
||||
|
||||
let jsonData = "[]";
|
||||
for(let v of info.widgets_values) {
|
||||
if (typeof v === "string" && v.startsWith("[") && v.endsWith("]")) {
|
||||
jsonData = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const savedLoras = JSON.parse(jsonData);
|
||||
for (let i = 0; i < savedLoras.length; i++) {
|
||||
const lora = savedLoras[i];
|
||||
const idx = i + 1;
|
||||
this.loraCount = idx;
|
||||
addLoraRow(this, idx, lora.name);
|
||||
|
||||
const wToggle = this.widgets.find(w => w.name === `enable_${idx}`);
|
||||
const wSegment = this.widgets.find(w => w.name === `segment_${idx}`);
|
||||
const wModel = this.widgets.find(w => w.name === `modelStr_${idx}`);
|
||||
|
||||
if (wToggle) wToggle.value = lora.enable;
|
||||
if (wSegment) wSegment.value = lora.segment;
|
||||
if (wModel) wModel.value = lora.modelStr;
|
||||
}
|
||||
|
||||
const wData = this.widgets.find(w => w.name === "lora_data");
|
||||
if (wData) {
|
||||
wData.type = "hidden";
|
||||
wData.hidden = true;
|
||||
wData.computeSize = () => [0, -4];
|
||||
wData.value = jsonData;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("[Rikan MultiLoraGate] Failed to parse saved JSON data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (onConfigure) {
|
||||
onConfigure.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
const onSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function (o) {
|
||||
if (onSerialize) onSerialize.apply(this, arguments);
|
||||
syncData(this);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,908 @@
|
||||
const { app } = window.comfyAPI.app;
|
||||
|
||||
const PALETTE = [
|
||||
"#4f8edc", "#e07b3a", "#5cb85c", "#d9534f", "#9b6cd6",
|
||||
"#a07060", "#e377c2", "#7f7f7f", "#c4c447", "#3fbac4",
|
||||
];
|
||||
|
||||
const RULER_HEIGHT = 22;
|
||||
const BLOCK_HEIGHT = 64;
|
||||
const CANVAS_HEIGHT = RULER_HEIGHT + BLOCK_HEIGHT;
|
||||
const HANDLE_HIT_PX = 6;
|
||||
const REORDER_THRESHOLD_PX = 6;
|
||||
const MIN_SEGMENT_LENGTH = 1;
|
||||
const HIDDEN_WIDGET_NAMES = ["timeline_data", "local_prompts", "segment_lengths"];
|
||||
|
||||
function hideWidget(w) {
|
||||
if (!w) return;
|
||||
w.type = "hidden";
|
||||
w.hidden = true;
|
||||
w.computeSize = () => [0, -4];
|
||||
}
|
||||
|
||||
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
||||
|
||||
function pickColor(existingColors) {
|
||||
for (const c of PALETTE) if (!existingColors.has(c)) return c;
|
||||
const idx = existingColors.size;
|
||||
const hue = (idx * 137.508) % 360;
|
||||
return `hsl(${hue.toFixed(0)}, 55%, 55%)`;
|
||||
}
|
||||
|
||||
function defaultTimeline(maxFrames) {
|
||||
const half = Math.max(MIN_SEGMENT_LENGTH, Math.floor(maxFrames / 2));
|
||||
return {
|
||||
segments: [
|
||||
{ prompt: "", length: half, color: PALETTE[0] },
|
||||
{ prompt: "", length: Math.max(MIN_SEGMENT_LENGTH, maxFrames - half), color: PALETTE[1] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseInitial(jsonStr, maxFrames) {
|
||||
if (!jsonStr) return defaultTimeline(maxFrames);
|
||||
try {
|
||||
const obj = JSON.parse(jsonStr);
|
||||
if (Array.isArray(obj?.segments) && obj.segments.length > 0) {
|
||||
return {
|
||||
segments: obj.segments.map((s, i) => ({
|
||||
prompt: typeof s.prompt === "string" ? s.prompt : "",
|
||||
length: Math.max(MIN_SEGMENT_LENGTH, parseInt(s.length, 10) || MIN_SEGMENT_LENGTH),
|
||||
color: typeof s.color === "string" ? s.color : PALETTE[i % PALETTE.length],
|
||||
})),
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
return defaultTimeline(maxFrames);
|
||||
}
|
||||
|
||||
class TimelineEditor {
|
||||
constructor(node, container) {
|
||||
this.node = node;
|
||||
this.container = container;
|
||||
|
||||
this.maxFramesWidget = node.widgets.find(w => w.name === "max_frames");
|
||||
this.fpsWidget = node.widgets.find(w => w.name === "fps");
|
||||
this.secondsWidget = node.widgets.find(w => w.name === "seconds");
|
||||
this.timeUnitsWidget = node.widgets.find(w => w.name === "time_units");
|
||||
this.timelineDataWidget = node.widgets.find(w => w.name === "timeline_data");
|
||||
this.localPromptsWidget = node.widgets.find(w => w.name === "local_prompts");
|
||||
this.segmentLengthsWidget = node.widgets.find(w => w.name === "segment_lengths");
|
||||
|
||||
this.timeline = parseInitial(this.timelineDataWidget?.value, this.getMaxFrames());
|
||||
this.selectedIndex = 0;
|
||||
this.hoverIndex = -1;
|
||||
this.hoverHandle = -1;
|
||||
this.dragHandle = -1;
|
||||
this.dragStart = null;
|
||||
this.reorder = null;
|
||||
this._settling = false;
|
||||
this._inputBaseline = null;
|
||||
this._textCommitTimer = null;
|
||||
this._displayedX = new Map();
|
||||
this._targetX = new Map();
|
||||
this._animRaf = null;
|
||||
|
||||
this.buildDOM();
|
||||
this.bindEvents();
|
||||
this.syncWidgetsFromTimeline();
|
||||
this.updateUIFromSelection();
|
||||
this.render();
|
||||
}
|
||||
|
||||
getMaxFrames() {
|
||||
return Math.max(1, parseInt(this.maxFramesWidget?.value, 10) || 1);
|
||||
}
|
||||
|
||||
getFps() {
|
||||
const v = parseFloat(this.fpsWidget?.value);
|
||||
return Number.isFinite(v) && v > 0 ? v : 24;
|
||||
}
|
||||
|
||||
isSecondsMode() {
|
||||
return this.timeUnitsWidget?.value === "seconds";
|
||||
}
|
||||
|
||||
formatTime(frames) {
|
||||
if (!this.isSecondsMode()) return String(frames);
|
||||
const s = frames / this.getFps();
|
||||
return `${s.toFixed(2).replace(/\.?0+$/, "")}s`;
|
||||
}
|
||||
|
||||
formatLength(frames) {
|
||||
return this.isSecondsMode() ? this.formatTime(frames) : `${frames}f`;
|
||||
}
|
||||
|
||||
buildDOM() {
|
||||
this.container.innerHTML = "";
|
||||
this.container.style.cssText = `
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 6px 8px; box-sizing: border-box;
|
||||
font-family: sans-serif; font-size: 11px; color: #ddd;
|
||||
width: 100%; height: 100%;
|
||||
`;
|
||||
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.style.cssText = `
|
||||
width: 100%; height: ${CANVAS_HEIGHT}px;
|
||||
display: block; background: #1a1a1a; border-radius: 4px;
|
||||
cursor: default;
|
||||
`;
|
||||
this.container.appendChild(this.canvas);
|
||||
this.ctx = this.canvas.getContext("2d");
|
||||
|
||||
this.textarea = document.createElement("textarea");
|
||||
this.textarea.placeholder = "Click a segment above to edit its prompt…";
|
||||
this.textarea.style.cssText = `
|
||||
width: 100%; min-height: 60px; flex: 1 1 auto;
|
||||
box-sizing: border-box; resize: none;
|
||||
background: #2a2a2a; color: #eee; border: 1px solid #444;
|
||||
border-radius: 4px; padding: 6px; font-family: inherit; font-size: 12px;
|
||||
`;
|
||||
this.container.appendChild(this.textarea);
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display: flex; gap: 6px; align-items: center;";
|
||||
|
||||
const lengthLabel = document.createElement("label");
|
||||
lengthLabel.style.cssText = "display: flex; align-items: center; gap: 4px;";
|
||||
lengthLabel.textContent = "Length:";
|
||||
this.lengthInput = document.createElement("input");
|
||||
this.lengthInput.type = "number";
|
||||
this.lengthInput.style.cssText = `
|
||||
width: 70px; background: #2a2a2a; color: #eee;
|
||||
border: 1px solid #444; border-radius: 3px; padding: 2px 4px;
|
||||
`;
|
||||
lengthLabel.appendChild(this.lengthInput);
|
||||
row.appendChild(lengthLabel);
|
||||
|
||||
this.totalLabel = document.createElement("span");
|
||||
this.totalLabel.style.cssText = "color: #888; margin-left: 4px;";
|
||||
row.appendChild(this.totalLabel);
|
||||
|
||||
const spacer = document.createElement("div");
|
||||
spacer.style.flex = "1";
|
||||
row.appendChild(spacer);
|
||||
|
||||
this.addBtn = this.makeButton("+ Add", "Add a new segment. Steals space from existing segments (from the end) if the timeline is full.");
|
||||
this.distributeBtn = this.makeButton("Equalize", "Set every segment to the same length so the total exactly fills max_frames.");
|
||||
this.deleteBtn = this.makeButton("Delete", "Remove the currently selected segment. Disabled when only one segment is left.");
|
||||
|
||||
row.appendChild(this.addBtn);
|
||||
row.appendChild(this.distributeBtn);
|
||||
row.appendChild(this.deleteBtn);
|
||||
|
||||
this.container.appendChild(row);
|
||||
}
|
||||
|
||||
makeButton(label, tooltip) {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = label;
|
||||
if (tooltip) b.title = tooltip;
|
||||
b.style.cssText = `
|
||||
background: #3a3a3a; color: #eee; border: 1px solid #555;
|
||||
border-radius: 3px; padding: 3px 10px; cursor: pointer; font-size: 11px;
|
||||
`;
|
||||
b.addEventListener("mouseenter", () => b.style.background = "#4a4a4a");
|
||||
b.addEventListener("mouseleave", () => b.style.background = "#3a3a3a");
|
||||
return b;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.canvas.addEventListener("pointerdown", e => { e.stopPropagation(); this.onPointerDown(e); });
|
||||
this.canvas.addEventListener("pointermove", e => { e.stopPropagation(); this.onPointerMove(e); });
|
||||
this.canvas.addEventListener("pointerup", e => { e.stopPropagation(); this.onPointerUp(e); });
|
||||
this.canvas.addEventListener("contextmenu", e => { e.preventDefault(); e.stopPropagation(); });
|
||||
this.canvas.addEventListener("wheel", e => e.stopPropagation(), { passive: true });
|
||||
this.canvas.addEventListener("pointerleave", () => {
|
||||
if (this.dragHandle < 0) {
|
||||
this.hoverIndex = -1;
|
||||
this.hoverHandle = -1;
|
||||
this.canvas.style.cursor = "default";
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
|
||||
this.textarea.addEventListener("wheel", e => e.stopPropagation(), { passive: true });
|
||||
this.textarea.addEventListener("pointerdown", e => e.stopPropagation());
|
||||
this.lengthInput.addEventListener("pointerdown", e => e.stopPropagation());
|
||||
this.lengthInput.addEventListener("wheel", e => e.stopPropagation(), { passive: true });
|
||||
|
||||
this.textarea.addEventListener("input", () => {
|
||||
const seg = this.timeline.segments[this.selectedIndex];
|
||||
if (!seg) return;
|
||||
seg.prompt = this.textarea.value;
|
||||
if (this.localPromptsWidget) {
|
||||
this.localPromptsWidget.value = this.timeline.segments.map(s => s.prompt).join(" | ");
|
||||
}
|
||||
this.render();
|
||||
if (this._textCommitTimer) clearTimeout(this._textCommitTimer);
|
||||
this._textCommitTimer = setTimeout(() => {
|
||||
this._textCommitTimer = null;
|
||||
this.commit();
|
||||
}, 120);
|
||||
});
|
||||
this.textarea.addEventListener("blur", () => {
|
||||
if (this._textCommitTimer) {
|
||||
clearTimeout(this._textCommitTimer);
|
||||
this._textCommitTimer = null;
|
||||
this.commit();
|
||||
}
|
||||
});
|
||||
this.lengthInput.addEventListener("focus", () => { this._inputBaseline = null; });
|
||||
this.lengthInput.addEventListener("blur", () => { this._inputBaseline = null; });
|
||||
this.lengthInput.addEventListener("input", () => {
|
||||
const idx = this.selectedIndex;
|
||||
const seg = this.timeline.segments[idx];
|
||||
if (!seg) return;
|
||||
const raw = parseFloat(this.lengthInput.value);
|
||||
if (!Number.isFinite(raw)) return;
|
||||
|
||||
const frames = Math.max(
|
||||
MIN_SEGMENT_LENGTH,
|
||||
Math.round(this.isSecondsMode() ? raw * this.getFps() : raw),
|
||||
);
|
||||
if (!this._inputBaseline) {
|
||||
this._inputBaseline = this.timeline.segments.map(s => s.length);
|
||||
}
|
||||
this._setLengthShifting(idx, frames, this._inputBaseline);
|
||||
this.commit();
|
||||
this.render();
|
||||
this.updateTotalLabel();
|
||||
});
|
||||
|
||||
this.addBtn.addEventListener("click", () => this.addSegment());
|
||||
this.distributeBtn.addEventListener("click", () => this.distributeEvenly());
|
||||
this.deleteBtn.addEventListener("click", () => this.deleteSelected());
|
||||
|
||||
if (this.maxFramesWidget) {
|
||||
const prev = this.maxFramesWidget.callback;
|
||||
this.maxFramesWidget.callback = (...args) => {
|
||||
prev?.apply(this.maxFramesWidget, args);
|
||||
if (this.secondsWidget && this.fpsWidget) {
|
||||
this.secondsWidget.value = parseFloat((this.maxFramesWidget.value / this.fpsWidget.value).toFixed(3));
|
||||
}
|
||||
this.trimToFit();
|
||||
this.commit();
|
||||
this.updateUIFromSelection();
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
if (this.secondsWidget) {
|
||||
const prev = this.secondsWidget.callback;
|
||||
this.secondsWidget.callback = (...args) => {
|
||||
prev?.apply(this.secondsWidget, args);
|
||||
if (this.maxFramesWidget && this.fpsWidget) {
|
||||
this.maxFramesWidget.value = Math.max(1, Math.round(this.secondsWidget.value * this.fpsWidget.value));
|
||||
this.trimToFit();
|
||||
this.commit();
|
||||
}
|
||||
this.updateUIFromSelection();
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
if (this.fpsWidget) {
|
||||
const prev = this.fpsWidget.callback;
|
||||
this.fpsWidget.callback = (...args) => {
|
||||
prev?.apply(this.fpsWidget, args);
|
||||
if (this.secondsWidget && this.maxFramesWidget) {
|
||||
this.secondsWidget.value = parseFloat((this.maxFramesWidget.value / this.fpsWidget.value).toFixed(3));
|
||||
}
|
||||
this.updateUIFromSelection();
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
if (this.timeUnitsWidget) {
|
||||
const prev = this.timeUnitsWidget.callback;
|
||||
this.timeUnitsWidget.callback = (...args) => {
|
||||
prev?.apply(this.timeUnitsWidget, args);
|
||||
this.updateUIFromSelection();
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => this.resizeCanvas());
|
||||
this.resizeObserver.observe(this.container);
|
||||
this.resizeCanvas();
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(50, Math.floor(this.canvas.offsetWidth));
|
||||
this.canvas.width = w * dpr;
|
||||
this.canvas.height = CANVAS_HEIGHT * dpr;
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
this._cssWidth = w;
|
||||
this.render();
|
||||
}
|
||||
|
||||
pxPerFrame() {
|
||||
return this._cssWidth / this.getMaxFrames();
|
||||
}
|
||||
|
||||
segmentRects(order) {
|
||||
const segs = this.timeline.segments;
|
||||
const ord = order ?? this._getEffectiveOrder();
|
||||
const ppf = this.pxPerFrame();
|
||||
const rects = [];
|
||||
let cursor = 0;
|
||||
for (let visualPos = 0; visualPos < ord.length; visualPos++) {
|
||||
const idx = ord[visualPos];
|
||||
const len = segs[idx].length;
|
||||
rects.push({ index: idx, visualPos, x: cursor * ppf, w: len * ppf, frameStart: cursor, frameEnd: cursor + len });
|
||||
cursor += len;
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
_getEffectiveOrder() {
|
||||
const n = this.timeline.segments.length;
|
||||
const natural = Array.from({ length: n }, (_, i) => i);
|
||||
if (!this.reorder?.active || this.reorder.targetIdx === this.reorder.sourceIdx) return natural;
|
||||
const order = natural.filter(i => i !== this.reorder.sourceIdx);
|
||||
order.splice(this.reorder.targetIdx, 0, this.reorder.sourceIdx);
|
||||
return order;
|
||||
}
|
||||
|
||||
_computeReorderTarget() {
|
||||
const ppf = this.pxPerFrame();
|
||||
const sourceIdx = this.reorder.sourceIdx;
|
||||
const sourceLen = this.timeline.segments[sourceIdx].length;
|
||||
const blockCenterFrame =
|
||||
(this.reorder.cursorX - this.reorder.dragOffsetPx) / ppf + sourceLen / 2;
|
||||
|
||||
const others = [];
|
||||
for (let i = 0; i < this.timeline.segments.length; i++) {
|
||||
if (i !== sourceIdx) others.push(this.timeline.segments[i].length);
|
||||
}
|
||||
|
||||
let bestTarget = 0;
|
||||
let bestDist = Infinity;
|
||||
let cum = 0;
|
||||
for (let target = 0; target <= others.length; target++) {
|
||||
const sourceCenter = cum + sourceLen / 2;
|
||||
const dist = Math.abs(blockCenterFrame - sourceCenter);
|
||||
if (dist < bestDist) { bestDist = dist; bestTarget = target; }
|
||||
if (target < others.length) cum += others[target];
|
||||
}
|
||||
return bestTarget;
|
||||
}
|
||||
|
||||
hitBoundary(mx) {
|
||||
const rects = this.segmentRects();
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
const right = rects[i].x + rects[i].w;
|
||||
if (Math.abs(mx - right) <= HANDLE_HIT_PX) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
hitBlock(mx, my) {
|
||||
if (my < RULER_HEIGHT) return -1;
|
||||
const rects = this.segmentRects();
|
||||
for (const r of rects) {
|
||||
if (mx >= r.x && mx < r.x + r.w) return r.index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
onPointerDown(e) {
|
||||
const { x, y } = this.localPos(e);
|
||||
this._settling = false;
|
||||
const handle = this.hitBoundary(x);
|
||||
if (handle >= 0) {
|
||||
this.dragHandle = handle;
|
||||
const segs = this.timeline.segments;
|
||||
this.dragStart = {
|
||||
x,
|
||||
initialLengths: segs.map(s => s.length),
|
||||
};
|
||||
this.canvas.setPointerCapture(e.pointerId);
|
||||
return;
|
||||
}
|
||||
const block = this.hitBlock(x, y);
|
||||
if (block >= 0) {
|
||||
this.selectedIndex = block;
|
||||
this.updateUIFromSelection();
|
||||
this.render();
|
||||
const sourceX = this._displayedX.get(block) ?? 0;
|
||||
this.reorder = {
|
||||
sourceIdx: block, targetIdx: block,
|
||||
startX: x, startY: y,
|
||||
cursorX: x,
|
||||
dragOffsetPx: x - sourceX,
|
||||
active: false,
|
||||
};
|
||||
try { this.canvas.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
onPointerMove(e) {
|
||||
const { x, y } = this.localPos(e);
|
||||
if (this.dragHandle >= 0) {
|
||||
const ppf = this.pxPerFrame();
|
||||
const dxFrames = Math.round((x - this.dragStart.x) / ppf);
|
||||
const handle = this.dragHandle;
|
||||
const initial = this.dragStart.initialLengths;
|
||||
this._setLengthShifting(handle, initial[handle] + dxFrames, initial);
|
||||
|
||||
const segs = this.timeline.segments;
|
||||
this.commit();
|
||||
if (segs[this.selectedIndex]) this.lengthInput.value = this.lengthInputValueFor(segs[this.selectedIndex].length);
|
||||
this.updateTotalLabel();
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reorder) {
|
||||
const dx = x - this.reorder.startX;
|
||||
const dy = y - this.reorder.startY;
|
||||
if (!this.reorder.active && Math.hypot(dx, dy) > REORDER_THRESHOLD_PX) {
|
||||
this.reorder.active = true;
|
||||
this.canvas.style.cursor = "grabbing";
|
||||
}
|
||||
if (this.reorder.active) {
|
||||
this.reorder.cursorX = x;
|
||||
this.reorder.targetIdx = this._computeReorderTarget();
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const handle = this.hitBoundary(x);
|
||||
const block = handle >= 0 ? -1 : this.hitBlock(x, y);
|
||||
if (handle !== this.hoverHandle || block !== this.hoverIndex) {
|
||||
this.hoverHandle = handle;
|
||||
this.hoverIndex = block;
|
||||
this.canvas.style.cursor = handle >= 0 ? "ew-resize" : (block >= 0 ? "pointer" : "default");
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp(e) {
|
||||
if (this.dragHandle >= 0) {
|
||||
try { this.canvas.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
this.dragHandle = -1;
|
||||
this.dragStart = null;
|
||||
}
|
||||
if (this.reorder) {
|
||||
try { this.canvas.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
if (this.reorder.active) {
|
||||
this.canvas.style.cursor = "default";
|
||||
const { sourceIdx, targetIdx } = this.reorder;
|
||||
if (sourceIdx !== targetIdx) {
|
||||
const effective = this._getEffectiveOrder();
|
||||
const oldDisplayed = new Map(this._displayedX);
|
||||
const seg = this.timeline.segments.splice(sourceIdx, 1)[0];
|
||||
this.timeline.segments.splice(targetIdx, 0, seg);
|
||||
this._displayedX = new Map();
|
||||
for (let newIdx = 0; newIdx < effective.length; newIdx++) {
|
||||
const oldIdx = effective[newIdx];
|
||||
if (oldDisplayed.has(oldIdx)) this._displayedX.set(newIdx, oldDisplayed.get(oldIdx));
|
||||
}
|
||||
this.selectedIndex = targetIdx;
|
||||
this.commit();
|
||||
this.updateUIFromSelection();
|
||||
this._settling = true;
|
||||
}
|
||||
}
|
||||
this.reorder = null;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
localPos(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const sx = (rect.width / this.canvas.offsetWidth) || 1;
|
||||
const sy = (rect.height / this.canvas.offsetHeight) || 1;
|
||||
return {
|
||||
x: (e.clientX - rect.left) / sx,
|
||||
y: (e.clientY - rect.top) / sy,
|
||||
};
|
||||
}
|
||||
|
||||
addSegment() {
|
||||
const max = this.getMaxFrames();
|
||||
const n = this.timeline.segments.length;
|
||||
if (max < (n + 1) * MIN_SEGMENT_LENGTH) return;
|
||||
|
||||
const desired = Math.max(MIN_SEGMENT_LENGTH, Math.floor(max / (n + 1)));
|
||||
const newIdx = n;
|
||||
const usedColors = new Set(this.timeline.segments.map(s => s.color));
|
||||
this.timeline.segments.push({ prompt: "", length: desired, color: pickColor(usedColors) });
|
||||
this.trimToFit(newIdx);
|
||||
|
||||
let total = this.timeline.segments.reduce((a, s) => a + s.length, 0);
|
||||
if (total > max) {
|
||||
this.timeline.segments[newIdx].length -= (total - max);
|
||||
}
|
||||
|
||||
this.selectedIndex = newIdx;
|
||||
this.commit();
|
||||
this.updateUIFromSelection();
|
||||
this.updateTotalLabel();
|
||||
this.render();
|
||||
}
|
||||
|
||||
maxLengthFor(idx) {
|
||||
const max = this.getMaxFrames();
|
||||
let others = 0;
|
||||
for (let i = 0; i < this.timeline.segments.length; i++) {
|
||||
if (i !== idx) others += this.timeline.segments[i].length;
|
||||
}
|
||||
return Math.max(MIN_SEGMENT_LENGTH, max - others);
|
||||
}
|
||||
|
||||
_setLengthShifting(idx, newLen, baseline) {
|
||||
const segs = this.timeline.segments;
|
||||
const max = this.getMaxFrames();
|
||||
for (let i = 0; i < segs.length; i++) segs[i].length = baseline[i];
|
||||
segs[idx].length = Math.max(MIN_SEGMENT_LENGTH, newLen);
|
||||
let total = segs.reduce((a, s) => a + s.length, 0);
|
||||
for (let i = idx + 1; i < segs.length && total > max; i++) {
|
||||
const reducible = segs[i].length - MIN_SEGMENT_LENGTH;
|
||||
const take = Math.min(reducible, total - max);
|
||||
segs[i].length -= take;
|
||||
total -= take;
|
||||
}
|
||||
if (total > max) segs[idx].length -= (total - max);
|
||||
}
|
||||
|
||||
trimToFit(protectIndex = -1) {
|
||||
const max = this.getMaxFrames();
|
||||
let total = this.timeline.segments.reduce((a, s) => a + s.length, 0);
|
||||
for (let i = this.timeline.segments.length - 1; i >= 0 && total > max; i--) {
|
||||
if (i === protectIndex) continue;
|
||||
const seg = this.timeline.segments[i];
|
||||
const reducible = seg.length - MIN_SEGMENT_LENGTH;
|
||||
const take = Math.min(reducible, total - max);
|
||||
seg.length -= take;
|
||||
total -= take;
|
||||
}
|
||||
}
|
||||
|
||||
distributeEvenly() {
|
||||
const max = this.getMaxFrames();
|
||||
const n = this.timeline.segments.length;
|
||||
if (n === 0) return;
|
||||
const base = Math.max(MIN_SEGMENT_LENGTH, Math.floor(max / n));
|
||||
const remainder = Math.max(0, max - base * n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.timeline.segments[i].length = base + (i < remainder ? 1 : 0);
|
||||
}
|
||||
this.commit();
|
||||
this.updateUIFromSelection();
|
||||
this.render();
|
||||
}
|
||||
|
||||
deleteSelected() {
|
||||
if (this.timeline.segments.length <= 1) return;
|
||||
this.timeline.segments.splice(this.selectedIndex, 1);
|
||||
this.selectedIndex = clamp(this.selectedIndex, 0, this.timeline.segments.length - 1);
|
||||
this.commit();
|
||||
this.updateUIFromSelection();
|
||||
this.updateTotalLabel();
|
||||
this.render();
|
||||
}
|
||||
|
||||
commit() {
|
||||
this.syncWidgetsFromTimeline();
|
||||
this.node.graph?.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
syncWidgetsFromTimeline() {
|
||||
const segs = this.timeline.segments;
|
||||
if (this.timelineDataWidget) this.timelineDataWidget.value = JSON.stringify(this.timeline);
|
||||
if (this.localPromptsWidget) this.localPromptsWidget.value = segs.map(s => s.prompt).join(" | ");
|
||||
if (this.segmentLengthsWidget) this.segmentLengthsWidget.value = segs.map(s => s.length).join(", ");
|
||||
}
|
||||
|
||||
lengthInputValueFor(frames) {
|
||||
if (!this.isSecondsMode()) return String(frames);
|
||||
return (frames / this.getFps()).toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
updateUIFromSelection() {
|
||||
const seg = this.timeline.segments[this.selectedIndex];
|
||||
if (!seg) {
|
||||
this.textarea.value = "";
|
||||
this.lengthInput.value = "";
|
||||
} else {
|
||||
if (this.textarea.value !== seg.prompt) this.textarea.value = seg.prompt;
|
||||
this.lengthInput.value = this.lengthInputValueFor(seg.length);
|
||||
}
|
||||
this.lengthInput.step = this.isSecondsMode() ? (1 / this.getFps()).toFixed(4) : "1";
|
||||
this.lengthInput.min = this.isSecondsMode() ? (MIN_SEGMENT_LENGTH / this.getFps()).toFixed(4) : MIN_SEGMENT_LENGTH;
|
||||
this._inputBaseline = null;
|
||||
this.updateTotalLabel();
|
||||
}
|
||||
|
||||
updateTotalLabel() {
|
||||
const total = this.timeline.segments.reduce((a, s) => a + s.length, 0);
|
||||
const max = this.getMaxFrames();
|
||||
if (this.isSecondsMode()) {
|
||||
const fps = this.getFps();
|
||||
const fmt = (f) => (f / fps).toFixed(2).replace(/\.?0+$/, "");
|
||||
this.totalLabel.textContent = `Total: ${fmt(total)} / ${fmt(max)} s @ ${fps}fps`;
|
||||
} else {
|
||||
this.totalLabel.textContent = `Total: ${total} / ${max} frames`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const rects = this.segmentRects();
|
||||
this._targetX = new Map();
|
||||
for (const r of rects) this._targetX.set(r.index, r.x);
|
||||
|
||||
if (this.reorder?.active) {
|
||||
const sourceIdx = this.reorder.sourceIdx;
|
||||
const sourcePos = this.reorder.cursorX - this.reorder.dragOffsetPx;
|
||||
this._targetX.set(sourceIdx, sourcePos);
|
||||
this._displayedX.set(sourceIdx, sourcePos);
|
||||
this._kickAnim();
|
||||
} else if (this._settling) {
|
||||
this._kickAnim();
|
||||
} else {
|
||||
for (const [idx, target] of this._targetX) this._displayedX.set(idx, target);
|
||||
this._draw();
|
||||
}
|
||||
}
|
||||
|
||||
_kickAnim() {
|
||||
if (this._animRaf) return;
|
||||
this._animRaf = requestAnimationFrame(() => this._tick());
|
||||
}
|
||||
|
||||
_tick() {
|
||||
this._animRaf = null;
|
||||
let needsMore = false;
|
||||
const speed = 0.15;
|
||||
for (const [idx, target] of this._targetX) {
|
||||
const cur = this._displayedX.get(idx);
|
||||
if (cur === undefined) { this._displayedX.set(idx, target); continue; }
|
||||
const diff = target - cur;
|
||||
if (Math.abs(diff) < 0.3) {
|
||||
this._displayedX.set(idx, target);
|
||||
} else {
|
||||
this._displayedX.set(idx, cur + diff * speed);
|
||||
needsMore = true;
|
||||
}
|
||||
}
|
||||
this._draw();
|
||||
if (needsMore) {
|
||||
this._animRaf = requestAnimationFrame(() => this._tick());
|
||||
} else if (!this.reorder?.active) {
|
||||
this._settling = false;
|
||||
}
|
||||
}
|
||||
|
||||
_draw() {
|
||||
const ctx = this.ctx;
|
||||
const w = this._cssWidth;
|
||||
ctx.clearRect(0, 0, w, CANVAS_HEIGHT);
|
||||
this.drawRuler(ctx, w);
|
||||
this.drawSegments(ctx, w);
|
||||
}
|
||||
|
||||
drawRuler(ctx, w) {
|
||||
const max = this.getMaxFrames();
|
||||
ctx.fillStyle = "#222";
|
||||
ctx.fillRect(0, 0, w, RULER_HEIGHT);
|
||||
|
||||
const ppf = this.pxPerFrame();
|
||||
const targetLabelSpacing = 60;
|
||||
|
||||
let step;
|
||||
if (this.isSecondsMode()) {
|
||||
const fps = this.getFps();
|
||||
const target = targetLabelSpacing / (ppf * fps);
|
||||
const nice = [0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300];
|
||||
let chosen = nice[nice.length - 1];
|
||||
for (const s of nice) { if (s >= target) { chosen = s; break; } }
|
||||
step = Math.max(1, Math.round(chosen * fps));
|
||||
} else {
|
||||
step = Math.max(1, Math.round(targetLabelSpacing / ppf));
|
||||
const niceSteps = [1, 2, 4, 5, 8, 10, 16, 20, 25, 50, 100];
|
||||
for (const s of niceSteps) { if (s >= step) { step = s; break; } }
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "#444";
|
||||
ctx.fillStyle = "#aaa";
|
||||
ctx.font = "10px sans-serif";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let f = 0; f <= max; f += step) {
|
||||
const x = Math.floor(f * ppf) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, RULER_HEIGHT - 6);
|
||||
ctx.lineTo(x, RULER_HEIGHT);
|
||||
ctx.stroke();
|
||||
ctx.fillText(this.formatTime(f), x + 2, 2);
|
||||
}
|
||||
const xMax = Math.floor(max * ppf) - 0.5;
|
||||
ctx.strokeStyle = "#666";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xMax, 0);
|
||||
ctx.lineTo(xMax, RULER_HEIGHT);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
drawSegments(ctx, w) {
|
||||
const rects = this.segmentRects();
|
||||
const blockY = RULER_HEIGHT + 2;
|
||||
const blockH = BLOCK_HEIGHT - 4;
|
||||
|
||||
ctx.fillStyle = "#101010";
|
||||
ctx.fillRect(0, blockY, w, blockH);
|
||||
|
||||
const dragIdx = this.reorder?.active ? this.reorder.sourceIdx : -1;
|
||||
const rendered = [...rects].sort((a, b) => (a.index === dragIdx ? 1 : 0) - (b.index === dragIdx ? 1 : 0));
|
||||
|
||||
for (const r of rendered) {
|
||||
const seg = this.timeline.segments[r.index];
|
||||
const color = seg.color || PALETTE[r.index % PALETTE.length];
|
||||
const isSelected = r.index === this.selectedIndex;
|
||||
const isHover = r.index === this.hoverIndex;
|
||||
const isDragging = r.index === dragIdx;
|
||||
|
||||
const drawX = Math.floor(this._displayedX.get(r.index) ?? r.x);
|
||||
const drawW = Math.max(2, Math.floor(r.w));
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = isDragging ? 0.9 : (isSelected ? 1.0 : (isHover ? 0.9 : 0.75));
|
||||
ctx.fillRect(drawX, blockY, drawW, blockH);
|
||||
ctx.globalAlpha = 1.0;
|
||||
|
||||
ctx.strokeStyle = isDragging ? "#ffd54f" : (isSelected ? "#fff" : "rgba(0,0,0,0.4)");
|
||||
ctx.lineWidth = isDragging || isSelected ? 2 : 1;
|
||||
ctx.strokeRect(drawX + 0.5, blockY + 0.5, drawW - 1, blockH - 1);
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "top";
|
||||
const label = seg.prompt || `(segment ${r.index + 1})`;
|
||||
const [line1, line2] = this.wrapTwoLines(ctx, label, drawW - 8);
|
||||
ctx.fillText(line1, drawX + 4, blockY + 4);
|
||||
if (line2) ctx.fillText(line2, drawX + 4, blockY + 18);
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.75)";
|
||||
ctx.font = "10px monospace";
|
||||
const range = `${this.formatTime(r.frameStart)}–${this.formatTime(r.frameEnd)} (${this.formatLength(seg.length)})`;
|
||||
const rangeTrunc = this.truncateText(ctx, range, drawW - 8);
|
||||
ctx.fillText(rangeTrunc, drawX + 4, blockY + blockH - 14);
|
||||
}
|
||||
|
||||
if (!this.reorder?.active) {
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
const r = rects[i];
|
||||
const drawX = this._displayedX.get(r.index) ?? r.x;
|
||||
const right = Math.floor(drawX + r.w);
|
||||
const isHover = i === this.hoverHandle || i === this.dragHandle;
|
||||
ctx.fillStyle = isHover ? "#fff" : "rgba(255,255,255,0.4)";
|
||||
ctx.fillRect(right - 1, blockY + 4, 2, blockH - 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
truncateText(ctx, text, maxWidth) {
|
||||
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||
let lo = 0, hi = text.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (ctx.measureText(text.slice(0, mid) + "…").width <= maxWidth) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo > 0 ? text.slice(0, lo) + "…" : "";
|
||||
}
|
||||
|
||||
wrapTwoLines(ctx, text, maxWidth) {
|
||||
if (ctx.measureText(text).width <= maxWidth) return [text, ""];
|
||||
|
||||
const tokens = text.split(/(\s+)/);
|
||||
let line1 = "";
|
||||
let consumed = 0;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const candidate = line1 + tokens[i];
|
||||
if (ctx.measureText(candidate).width > maxWidth) break;
|
||||
line1 = candidate;
|
||||
consumed = i + 1;
|
||||
}
|
||||
|
||||
if (!line1.trim()) return [this.truncateText(ctx, text, maxWidth), ""];
|
||||
|
||||
let line2 = tokens.slice(consumed).join("").trim();
|
||||
if (!line2) return [line1.trimEnd(), ""];
|
||||
if (ctx.measureText(line2).width > maxWidth) {
|
||||
line2 = this.truncateText(ctx, line2, maxWidth);
|
||||
}
|
||||
return [line1.trimEnd(), line2];
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.resizeObserver?.disconnect();
|
||||
if (this._animRaf) cancelAnimationFrame(this._animRaf);
|
||||
if (this._textCommitTimer) {
|
||||
clearTimeout(this._textCommitTimer);
|
||||
this._textCommitTimer = null;
|
||||
try { this.commit(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Menambahkan default untuk seconds agar node lama yang belum punya tidak error
|
||||
const APPENDED_WIDGET_DEFAULTS = [["fps", 24.0], ["seconds", 5.375], ["time_units", "seconds"]];
|
||||
|
||||
app.registerExtension({
|
||||
name: "Rikan.PromptRelay.Timeline", // <-- UBAH JADI UNIK
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== "RikanPromptRelayEncodeTimeline") return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
for (const name of HIDDEN_WIDGET_NAMES) {
|
||||
hideWidget(this.widgets.find(w => w.name === name));
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
// UBAH NAMA WIDGET DOM AGAR UNIK
|
||||
this._timelineWidget = this.addDOMWidget("rikan_prompt_relay_timeline", "RikanPromptRelayTimeline", container, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => 220,
|
||||
getHeight: () => 220,
|
||||
});
|
||||
|
||||
const self = this;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
self._timelineEditor = new TimelineEditor(self, container);
|
||||
} catch (err) {
|
||||
console.error("[PromptRelay] timeline editor init failed:", err);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
const onRemoved = this.onRemoved;
|
||||
this.onRemoved = function () {
|
||||
this._timelineEditor?.destroy();
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const onConfigure = this.onConfigure;
|
||||
this.onConfigure = function (info) {
|
||||
const out = onConfigure?.apply(this, arguments);
|
||||
for (const [name, def] of APPENDED_WIDGET_DEFAULTS) {
|
||||
const w = this.widgets.find(x => x.name === name);
|
||||
if (w && (w.value == null || w.value === "")) w.value = def;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (this._timelineEditor) {
|
||||
if (this._timelineEditor.secondsWidget && this._timelineEditor.fpsWidget && this._timelineEditor.maxFramesWidget) {
|
||||
this._timelineEditor.secondsWidget.value = parseFloat((this._timelineEditor.maxFramesWidget.value / this._timelineEditor.fpsWidget.value).toFixed(3));
|
||||
}
|
||||
this._timelineEditor.timeline = parseInitial(
|
||||
this._timelineEditor.timelineDataWidget?.value,
|
||||
this._timelineEditor.getMaxFrames(),
|
||||
);
|
||||
this._timelineEditor.selectedIndex = clamp(
|
||||
this._timelineEditor.selectedIndex, 0,
|
||||
this._timelineEditor.timeline.segments.length - 1,
|
||||
);
|
||||
this._timelineEditor.updateUIFromSelection();
|
||||
this._timelineEditor.render();
|
||||
}
|
||||
}, 10);
|
||||
return out;
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user