diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..11987fc --- /dev/null +++ b/nodes.py @@ -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", +} \ No newline at end of file diff --git a/web/hidden_base64_loader.js b/web/hidden_base64_loader.js new file mode 100644 index 0000000..cbc8c41 --- /dev/null +++ b/web/hidden_base64_loader.js @@ -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; + } + } + }; + } + } +}); \ No newline at end of file diff --git a/web/multi_lora_gate.js b/web/multi_lora_gate.js new file mode 100644 index 0000000..84f89fc --- /dev/null +++ b/web/multi_lora_gate.js @@ -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); + }; + } + } +}); \ No newline at end of file diff --git a/web/prompt_relay_timeline.js b/web/prompt_relay_timeline.js new file mode 100644 index 0000000..496b6e7 --- /dev/null +++ b/web/prompt_relay_timeline.js @@ -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; + }; + }, +}); \ No newline at end of file