Sync to registry release 0.1.1: pyproject + requirements (Registry manifest), .comfyignore, RIFE-only Frame-Interpolation, S1 (no runtime pip/git), comfy node validate fixes, insightface>=1.0 pin

This commit is contained in:
chrmc40
2026-06-22 20:18:51 -07:00
parent 92d70d9c46
commit 799c3f5448
106 changed files with 17538 additions and 3130 deletions
+24
View File
@@ -0,0 +1,24 @@
# Files excluded from the published ComfyUI Registry tarball (gitignore syntax).
# Dev-only / build artifacts — never needed by an installed pack.
#
# NOTE: bundled_packs/, data/ (tag DB + thumbs), and js/ assets stay IN — they're
# the batteries-included feature set and are intentionally shipped.
# dev test harnesses
tests/
# comfy-node-publish build artifact
node.zip
# dev-only natlang probe scripts — never imported at runtime. The 6 runtime-imported
# scripts (natlang_decompose_probe, natlang_compose_from_plan, natlang_scene_composer,
# natlang_hybrid_v1, natlang_rails_v2, tag_rails_v1) are deliberately NOT listed.
scripts/natlang_char_router_probe.py
scripts/natlang_fragment_rewrite_harness.py
scripts/natlang_harmonize_probe.py
scripts/natlang_locate_infill_probe.py
scripts/natlang_planner_probe.py
scripts/natlang_resolve_probe.py
scripts/natlang_scan_probe.py
scripts/natlang_vibe_probe.py
scripts/natlang_dispatch.py
+2
View File
@@ -30,3 +30,5 @@ cache/
# dev-only probes/harnesses/tests moved out of scripts/ (which keeps only the
# runtime-imported modules) — kept local, never shipped
/_harness/
# comfy-node-publish build artifact (the uploaded tarball) — never commit it
/node.zip
+3 -1
View File
@@ -12,6 +12,7 @@ from .nodes.pose_studio import PoseStudioNode
from .nodes.attention_couple import AttentionCoupleNode
from .nodes.regional_detailer import RegionalDetailerNode, MaskedDetailNode
from .nodes.regional_conditioning import RegionalConditioningNode
from .nodes.region_box import RegionBoxNode
from .nodes.defocus_mask import DefocusMaskNode
from .nodes.ideogram_sampler import IdeogramSamplerNode
from .nodes.ideogram_caption import IdeogramCaptionNode
@@ -54,6 +55,7 @@ from .core import pose_api # noqa: F401
from .core import inpaint_files # noqa: F401 (age-sweeps input/promptchain_inpaint at startup)
from .core import upscale_api # noqa: F401
from .core import edit_api # noqa: F401
from .core import extend_api # noqa: F401
from .core import modal_setup_api # noqa: F401
from .core import subject_api # noqa: F401
from .core import install_sections # noqa: F401
@@ -222,7 +224,7 @@ _threading.Thread(target=_preload, daemon=True).start()
class PromptChainExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [PromptChainNode, PoseStudioNode, AttentionCoupleNode, RegionalDetailerNode, MaskedDetailNode, RegionalConditioningNode, DefocusMaskNode, IdeogramSamplerNode, IdeogramCaptionNode]
return [PromptChainNode, PoseStudioNode, AttentionCoupleNode, RegionalDetailerNode, MaskedDetailNode, RegionalConditioningNode, RegionBoxNode, DefocusMaskNode, IdeogramSamplerNode, IdeogramCaptionNode]
async def comfy_entrypoint() -> PromptChainExtension:
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Fannovel16
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,22 @@
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
from .other_nodes import Gradually_More_Denoise_KSampler
# Trimmed to RIFE only: PromptChain's Smooth/Extend use "RIFE VFI" exclusively.
# The other interpolation models were never wired up and carried unclear/
# non-commercial upstream licenses, so they can't ride along in a redistributed pack.
from vfi_models.rife import RIFE_VFI
from vfi_utils import MakeInterpolationStateList, FloatToInt
NODE_CLASS_MAPPINGS = {
"KSampler Gradually Adding More Denoise (efficient)": Gradually_More_Denoise_KSampler,
"RIFE VFI": RIFE_VFI,
"Make Interpolation State List": MakeInterpolationStateList,
"VFI FloatToInt": FloatToInt
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RIFE VFI": "RIFE VFI (recommend rife4.25+)"
}
@@ -0,0 +1,3 @@
#Plz don't delete this file, just edit it when neccessary.
ckpts_path: "./ckpts"
ops_backend: "cupy" #Either "taichi" or "cupy"
@@ -0,0 +1,88 @@
import latent_preview
import comfy
import einops
import torch
def common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent, denoise=1.0, disable_noise=False, start_step=None, last_step=None, force_full_denoise=False):
device = comfy.model_management.get_torch_device()
latent_image = latent["samples"]
if disable_noise:
noise = torch.zeros(latent_image.size(), dtype=latent_image.dtype, layout=latent_image.layout, device="cpu")
else:
batch_inds = latent["batch_index"] if "batch_index" in latent else None
noise = comfy.sample.prepare_noise(latent_image, seed, batch_inds)
noise_mask = None
if "noise_mask" in latent:
noise_mask = latent["noise_mask"]
preview_format = "JPEG"
if preview_format not in ["JPEG", "PNG"]:
preview_format = "JPEG"
previewer = latent_preview.get_previewer(device, model.model.latent_format)
pbar = comfy.utils.ProgressBar(steps)
def callback(step, x0, x, total_steps):
preview_bytes = None
if previewer:
preview_bytes = previewer.decode_latent_to_preview_image(preview_format, x0)
pbar.update_absolute(step + 1, total_steps, preview_bytes)
samples = comfy.sample.sample(model, noise, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise, disable_noise=disable_noise, start_step=start_step, last_step=last_step,
force_full_denoise=force_full_denoise, noise_mask=noise_mask, callback=callback, seed=seed)
out = latent.copy()
out["samples"] = samples
return (out, )
class Gradually_More_Denoise_KSampler:
@classmethod
def INPUT_TYPES(s):
return {"required":
{"model": ("MODEL",),
"positive": ("CONDITIONING", ),
"negative": ("CONDITIONING", ),
"latent_image": ("LATENT", ),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
"cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, ),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, ),
"start_denoise": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"denoise_increment": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.1}),
"denoise_increment_steps": ("INT", {"default": 20, "min": 1, "max": 10000})
},
"optional": { "optional_vae": ("VAE",) }
}
RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "LATENT", "VAE", )
RETURN_NAMES = ("MODEL", "CONDITIONING+", "CONDITIONING-", "LATENT", "VAE", )
OUTPUT_NODE = True
FUNCTION = "sample"
CATEGORY = "ComfyUI-Frame-Interpolation/others"
def sample(self, model, positive, negative, latent_image, optional_vae,
seed, steps, cfg, sampler_name, scheduler,start_denoise, denoise_increment, denoise_increment_steps):
if start_denoise + denoise_increment * denoise_increment_steps > 1.0:
raise Exception(f"Max denoise strength can't over 1.0 (start_denoise={start_denoise}, denoise_increment={denoise_increment}, denoise_increment_steps={denoise_increment_steps}")
copied_latent = latent_image.copy()
out_samples = []
for latent_sample in copied_latent["samples"]:
latent = {"samples": einops.rearrange(latent_sample, "c h w -> 1 c h w")}
#Latent's shape is NCHW
gradually_denoising_samples = [
common_ksampler(
model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent, denoise=start_denoise + denoise_increment * i
)[0]["samples"]
for i in range(denoise_increment_steps)
]
out_samples.extend(gradually_denoising_samples)
copied_latent["samples"] = torch.cat(out_samples, dim=0)
return (model, positive, negative, copied_latent, optional_vae)
@@ -0,0 +1,13 @@
[project]
name = "comfyui-frame-interpolation"
description = "A custom node suite for Video Frame Interpolation in ComfyUI"
version = "1.0.11"
license = { file = "LICENSE" }
[project.urls]
Repository = "https://github.com/Fannovel16/ComfyUI-Frame-Interpolation"
[tool.comfy]
PublisherId = "fannovel16"
DisplayName = "ComfyUI-Frame-Interpolation"
Icon = ""
@@ -0,0 +1 @@
einops
@@ -0,0 +1,239 @@
import gc
import torch
import pathlib
from vfi_utils import load_file_from_github_release, preprocess_frames, postprocess_frames, InterpolationStateList
import typing
from comfy.model_management import get_torch_device, soft_empty_cache
from packaging import version
MODEL_TYPE = pathlib.Path(__file__).parent.name
CKPT_NAME_VER_DICT = {
"rife47.pth": "4.7",
"rife49.pth": "4.7",
"rife417.pth": "4.17",
"rife426.pth": "4.26",
"sudo_rife4_269.662_testV1_scale1.pth": "4.0",
# Arch 4.10 doesn't work due to state dict mismatch
# "rife410.pth": "4.10",
# "rife411.pth": "4.10",
# "rife412.pth": "4.10"
}
DTYPE_OPTIONS = ["float32", "float16", "bfloat16"]
DTYPE_MAP = {
"float32": torch.float32,
"float16": torch.float16,
"bfloat16": torch.bfloat16,
}
# Module-level model cache: avoids reloading weights on every node execution.
# Key: (ckpt_name, dtype, torch_compile) — invalidated if any of these change.
_model_cache: typing.Dict[typing.Tuple, torch.nn.Module] = {}
class RIFE_VFI:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"ckpt_name": (
sorted(list(CKPT_NAME_VER_DICT.keys()), key=lambda ckpt_name: version.parse(CKPT_NAME_VER_DICT[ckpt_name])),
{"default": "rife49.pth"}
),
"frames": ("IMAGE", ),
"clear_cache_after_n_frames": ("INT", {"default": 10, "min": 1, "max": 1000}),
"multiplier": ("INT", {"default": 2, "min": 1}),
"fast_mode": ("BOOLEAN", {"default": True}),
"ensemble": ("BOOLEAN", {"default": True}),
"scale_factor": ([0.25, 0.5, 1.0, 2.0, 4.0], {"default": 1.0}),
# Opt 1: Precision — float32 is safe, float16 is ~2x faster/half VRAM,
# bfloat16 is safer than float16 on Ampere+ GPUs (RTX 30xx+)
"dtype": (DTYPE_OPTIONS, {"default": "float32"}),
# Opt 2: torch.compile() — JIT-compiles the model graph for 10-30% speedup.
# First inference will be slow (compilation), subsequent ones faster.
# Requires PyTorch 2.0+. Disable if you hit errors on older builds.
"torch_compile": ("BOOLEAN", {"default": False,
"tooltip": "Compile the model with torch.compile() for 10-30% faster inference "
"after the first (warm-up) run. Requires PyTorch 2.0+."}),
# Opt 7: Batch tasks — process multiple (pair, timestep) tasks per GPU call.
# Each task is one interpolated frame. Batching amortises kernel launch
# overhead; higher values improve throughput but use more VRAM.
# 1 = one interpolated frame per call (safest).
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64,
"tooltip": "Number of interpolation tasks per GPU call. "
"Higher values improve throughput but use more VRAM. "
"Set to 1 for the most conservative behaviour."}),
},
"optional": {
"optional_interpolation_states": ("INTERPOLATION_STATES", )
}
}
RETURN_TYPES = ("IMAGE", )
FUNCTION = "vfi"
CATEGORY = "ComfyUI-Frame-Interpolation/VFI"
def vfi(
self,
ckpt_name: typing.AnyStr,
frames: torch.Tensor,
clear_cache_after_n_frames: int = 10,
multiplier: typing.SupportsInt = 2,
fast_mode: bool = False,
ensemble: bool = False,
scale_factor: float = 1.0,
dtype: str = "float32",
torch_compile: bool = False,
batch_size: int = 1,
optional_interpolation_states: InterpolationStateList = None,
**kwargs
):
"""
Perform video frame interpolation using a given checkpoint model.
Args:
ckpt_name (str): The name of the checkpoint model to use.
frames (torch.Tensor): A tensor containing input video frames.
clear_cache_after_n_frames (int, optional): The number of frames to process before clearing CUDA cache
to prevent memory overflow. Defaults to 10. Lower numbers are safer but mean more processing time.
How high you should set it depends on how many input frames there are, input resolution (after upscaling),
how many times you want to multiply them, and how long you're willing to wait for the process to complete.
multiplier (int, optional): The multiplier for each input frame. 60 input frames * 2 = 120 output frames. Defaults to 2.
dtype (str, optional): Floating-point precision for inference.
"float32" — full precision, safest (default).
"float16" — half precision, ~2x faster and ~50% less VRAM, may have minor artifacts.
"bfloat16" — brain float16, better numeric range than float16, requires Ampere+ GPU (RTX 30xx+).
torch_compile (bool, optional): Compile the model with torch.compile() for 10-30% speedup.
The first inference call will be slow (compilation warm-up); subsequent calls are faster.
Requires PyTorch 2.0+. Defaults to False.
batch_size (int, optional): Number of interpolation tasks per GPU call. Each task produces one
intermediate frame. Higher values improve throughput but use more VRAM. Defaults to 1.
Returns:
tuple: A tuple containing the output interpolated frames.
"""
from .rife_arch import IFNet
model_path = load_file_from_github_release(MODEL_TYPE, ckpt_name)
arch_ver = CKPT_NAME_VER_DICT[ckpt_name]
torch_dtype = DTYPE_MAP[dtype]
device = get_torch_device()
# 4.26 disable ensemble
if arch_ver == "4.26":
ensemble = False # 4.26 版本不支持 ensemble
# Cache the model by (ckpt_name, dtype, torch_compile) so repeated node
# executions skip the load_state_dict + device transfer entirely.
cache_key = (ckpt_name, dtype, torch_compile)
if cache_key not in _model_cache:
interpolation_model = IFNet(arch_ver=arch_ver)
interpolation_model.load_state_dict(torch.load(model_path, weights_only=False))
if torch_dtype != torch.float32:
interpolation_model = interpolation_model.to(torch_dtype)
interpolation_model.eval().to(device)
# Opt 2: torch.compile() — JIT-compiles the model for 10-30% faster inference.
# The first call triggers compilation (slow); all subsequent calls use the cached graph.
if torch_compile:
interpolation_model = torch.compile(interpolation_model)
_model_cache[cache_key] = interpolation_model
print(f"Comfy-VFI: Loaded and cached model {ckpt_name} ({dtype}{'+ torch.compile' if torch_compile else ''})")
else:
interpolation_model = _model_cache[cache_key]
print(f"Comfy-VFI: Using cached model {ckpt_name} ({dtype}{'+ torch.compile' if torch_compile else ''})")
frames = preprocess_frames(frames)
# Normalise multiplier to a per-pair list
n_pairs = len(frames) - 1
if isinstance(multiplier, int):
multipliers = [int(multiplier)] * n_pairs
else:
multipliers = list(map(int, multiplier))
multipliers += [2] * (n_pairs - len(multipliers))
# 4.26 scale_list
if arch_ver == "4.26":
scale_list = [16 / scale_factor, 8 / scale_factor, 4 / scale_factor, 2 / scale_factor, 1 / scale_factor]
else:
scale_list = [8 / scale_factor, 4 / scale_factor, 2 / scale_factor, 1 / scale_factor]
# Build a flat list of all (pair_idx, timestep) tasks, skipping excluded pairs.
# Each task produces exactly one intermediate frame.
tasks: typing.List[typing.Tuple[int, float]] = []
tasks_remaining_per_pair: typing.Dict[int, int] = {}
for pair_idx in range(n_pairs):
if optional_interpolation_states is not None and optional_interpolation_states.is_frame_skipped(pair_idx):
tasks_remaining_per_pair[pair_idx] = 0
continue
m = multipliers[pair_idx]
n_steps = max(m - 1, 0)
tasks_remaining_per_pair[pair_idx] = n_steps
for step in range(1, m):
tasks.append((pair_idx, step / m))
# Storage for intermediate frames, keyed by pair index
results: typing.Dict[int, typing.List[torch.Tensor]] = {i: [] for i in range(n_pairs)}
frames_processed_since_cache_clear = 0
pos = 0
# Opt 5: inference_mode disables autograd tracking entirely — lower overhead than no_grad().
# Opt 7: process batch_size tasks per GPU call. IFNet supports batched tensor timesteps,
# so multiple (pair, timestep) combinations can be inferred in a single forward pass.
with torch.inference_mode():
while pos < len(tasks):
batch_tasks = tasks[pos : pos + batch_size]
frame0_list, frame1_list, timestep_list = [], [], []
for pair_idx, dt in batch_tasks:
frame0_list.append(frames[pair_idx : pair_idx + 1])
frame1_list.append(frames[pair_idx + 1 : pair_idx + 2])
timestep_list.append(dt)
frame0_batch = torch.cat(frame0_list, dim=0).to(device, dtype=torch_dtype)
frame1_batch = torch.cat(frame1_list, dim=0).to(device, dtype=torch_dtype)
# Batched timestep tensor: shape [B, 1, 1, 1] — IFNet expands it internally
timestep_tensor = torch.tensor(timestep_list, dtype=torch_dtype, device=device).view(-1, 1, 1, 1)
middle_frames = interpolation_model(
frame0_batch,
frame1_batch,
timestep_tensor,
scale_list,
fast_mode,
ensemble,
).clamp(0, 1).detach().cpu()
for idx, (pair_idx, _) in enumerate(batch_tasks):
results[pair_idx].append(middle_frames[idx : idx + 1].to(dtype=torch_dtype))
tasks_remaining_per_pair[pair_idx] -= 1
# Clear cache after finishing each pair's worth of tasks
if tasks_remaining_per_pair[pair_idx] == 0:
frames_processed_since_cache_clear += 1
if frames_processed_since_cache_clear >= clear_cache_after_n_frames:
print("Comfy-VFI: Clearing cache...", end=' ')
soft_empty_cache()
gc.collect()
frames_processed_since_cache_clear = 0
print("Done cache clearing")
pos += len(batch_tasks)
# Assemble output: each original frame followed by its interpolated frames
output_frames: typing.List[torch.Tensor] = []
for pair_idx in range(n_pairs):
output_frames.append(frames[pair_idx : pair_idx + 1].to(dtype=torch_dtype))
for mid in results[pair_idx]:
output_frames.append(mid)
output_frames.append(frames[-1:].to(dtype=torch_dtype))
print("Comfy-VFI: Final clearing cache...", end=' ')
soft_empty_cache()
print("Done cache clearing")
print(f"Comfy-VFI done! {sum(len(v) for v in results.values()) + len(frames)} frames generated")
# Always return float32 — numpy and all downstream ComfyUI nodes require it
out_tensor = torch.cat(output_frames, dim=0).to(torch.float32)
return (postprocess_frames(out_tensor),)
@@ -0,0 +1,732 @@
"""
26-Dez-21
https://github.com/hzwer/Practical-RIFE
https://github.com/hzwer/Practical-RIFE/blob/main/model/warplayer.py
https://github.com/HolyWu/vs-rife/blob/master/vsrife/__init__.py
"""
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.optim import AdamW
import torch
import torch.nn.functional as F
import torch.nn as nn
import torch.optim as optim
import warnings
from comfy.model_management import get_torch_device
device = get_torch_device()
backwarp_tenGrid = {}
class ResConv(nn.Module):
def __init__(self, c, dilation=1):
super(ResConv, self).__init__()
self.conv = nn.Conv2d(c, c, 3, 1, dilation, dilation=dilation, groups=1)
self.beta = nn.Parameter(torch.ones((1, c, 1, 1)), requires_grad=True)
self.relu = nn.LeakyReLU(0.2, True)
def forward(self, x):
return self.relu(self.conv(x) * self.beta + x)
def warp(tenInput, tenFlow):
k = (str(tenFlow.device), str(tenFlow.size()))
if k not in backwarp_tenGrid:
tenHorizontal = (
torch.linspace(-1.0, 1.0, tenFlow.shape[3], device=device)
.view(1, 1, 1, tenFlow.shape[3])
.expand(tenFlow.shape[0], -1, tenFlow.shape[2], -1)
)
tenVertical = (
torch.linspace(-1.0, 1.0, tenFlow.shape[2], device=device)
.view(1, 1, tenFlow.shape[2], 1)
.expand(tenFlow.shape[0], -1, -1, tenFlow.shape[3])
)
backwarp_tenGrid[k] = torch.cat([tenHorizontal, tenVertical], 1).to(device)
tenFlow = torch.cat(
[
tenFlow[:, 0:1, :, :] / ((tenInput.shape[3] - 1.0) / 2.0),
tenFlow[:, 1:2, :, :] / ((tenInput.shape[2] - 1.0) / 2.0),
],
1,
)
g = (backwarp_tenGrid[k] + tenFlow).permute(0, 2, 3, 1)
if g.dtype != tenInput.dtype:
g = g.to(tenInput.dtype)
padding_mode = "border"
if device.type == "mps":
# https://github.com/pytorch/pytorch/issues/125098
padding_mode = "zeros"
g = g.clamp(-1, 1)
return torch.nn.functional.grid_sample(
input=tenInput,
grid=g,
mode="bilinear",
padding_mode=padding_mode,
align_corners=True,
)
def conv(
in_planes,
out_planes,
kernel_size=3,
stride=1,
padding=1,
dilation=1,
arch_ver="4.0",
):
if arch_ver == "4.0":
return nn.Sequential(
nn.Conv2d(
in_planes,
out_planes,
kernel_size=kernel_size,
stride=stride,
padding=padding,
dilation=dilation,
bias=True,
),
nn.PReLU(out_planes),
)
if arch_ver in ["4.2", "4.3", "4.5", "4.6", "4.7", "4.10", "4.17", "4.26"]:
return nn.Sequential(
nn.Conv2d(
in_planes,
out_planes,
kernel_size=kernel_size,
stride=stride,
padding=padding,
dilation=dilation,
bias=True,
),
nn.LeakyReLU(0.2, True),
)
def conv_woact(in_planes, out_planes, kernel_size=3, stride=1, padding=1, dilation=1):
return nn.Sequential(
nn.Conv2d(
in_planes,
out_planes,
kernel_size=kernel_size,
stride=stride,
padding=padding,
dilation=dilation,
bias=True,
),
)
def conv_woact(in_planes, out_planes, kernel_size=3, stride=1, padding=1, dilation=1):
return nn.Sequential(
nn.Conv2d(
in_planes,
out_planes,
kernel_size=kernel_size,
stride=stride,
padding=padding,
dilation=dilation,
bias=True,
)
)
def deconv(in_planes, out_planes, kernel_size=4, stride=2, padding=1, arch_ver="4.0"):
if arch_ver == "4.0":
return nn.Sequential(
torch.nn.ConvTranspose2d(
in_channels=in_planes,
out_channels=out_planes,
kernel_size=4,
stride=2,
padding=1,
bias=True,
),
nn.PReLU(out_planes),
)
if arch_ver in ["4.2", "4.3", "4.5", "4.6", "4.7", "4.10", "4.17", "4.26"]:
return nn.Sequential(
torch.nn.ConvTranspose2d(
in_channels=in_planes,
out_channels=out_planes,
kernel_size=4,
stride=2,
padding=1,
bias=True,
),
nn.LeakyReLU(0.2, True),
)
class Conv2(nn.Module):
def __init__(self, in_planes, out_planes, stride=2, arch_ver="4.0"):
super(Conv2, self).__init__()
self.conv1 = conv(in_planes, out_planes, 3, stride, 1, arch_ver=arch_ver)
self.conv2 = conv(out_planes, out_planes, 3, 1, 1, arch_ver=arch_ver)
def forward(self, x):
x = self.conv1(x)
x = self.conv2(x)
return x
class IFBlock(nn.Module):
def __init__(self, in_planes, c=64, arch_ver="4.0"):
super(IFBlock, self).__init__()
self.arch_ver = arch_ver
self.conv0 = nn.Sequential(
conv(in_planes, c // 2, 3, 2, 1, arch_ver=arch_ver),
conv(c // 2, c, 3, 2, 1, arch_ver=arch_ver),
)
self.arch_ver = arch_ver
if arch_ver in ["4.0", "4.2", "4.3"]:
self.convblock = nn.Sequential(
conv(c, c, arch_ver=arch_ver),
conv(c, c, arch_ver=arch_ver),
conv(c, c, arch_ver=arch_ver),
conv(c, c, arch_ver=arch_ver),
conv(c, c, arch_ver=arch_ver),
conv(c, c, arch_ver=arch_ver),
conv(c, c, arch_ver=arch_ver),
conv(c, c, arch_ver=arch_ver),
)
self.lastconv = nn.ConvTranspose2d(c, 5, 4, 2, 1)
if arch_ver in ["4.5", "4.6", "4.7", "4.10", "4.17"]:
self.convblock = nn.Sequential(
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
)
if arch_ver == "4.5":
self.lastconv = nn.Sequential(
nn.ConvTranspose2d(c, 4 * 5, 4, 2, 1), nn.PixelShuffle(2)
)
if arch_ver in ["4.6", "4.7", "4.10", "4.17"]:
self.lastconv = nn.Sequential(
nn.ConvTranspose2d(c, 4 * 6, 4, 2, 1), nn.PixelShuffle(2)
)
# add 4.26 support
if arch_ver in ["4.26"]:
self.convblock = nn.Sequential(
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
)
self.lastconv = nn.Sequential(
nn.ConvTranspose2d(c, 4*13, 4, 2, 1),
nn.PixelShuffle(2)
)
def forward(self, x, flow=None, scale=1):
x = F.interpolate(
x, scale_factor=1.0 / scale, mode="bilinear", align_corners=False
)
if flow is not None:
flow = (
F.interpolate(
flow, scale_factor=1.0 / scale, mode="bilinear", align_corners=False
)
* 1.0
/ scale
)
x = torch.cat((x, flow), 1)
feat = self.conv0(x)
if self.arch_ver == "4.0":
feat = self.convblock(feat) + feat
if self.arch_ver in ["4.2", "4.3", "4.5", "4.6", "4.7", "4.10", "4.17", "4.26"]:
feat = self.convblock(feat)
tmp = self.lastconv(feat)
if self.arch_ver in ["4.0", "4.2", "4.3"]:
tmp = F.interpolate(
tmp, scale_factor=scale * 2, mode="bilinear", align_corners=False
)
flow = tmp[:, :4] * scale * 2
if self.arch_ver in ["4.5", "4.6", "4.7", "4.10", "4.17"]:
tmp = F.interpolate(
tmp, scale_factor=scale, mode="bilinear", align_corners=False
)
flow = tmp[:, :4] * scale
if self.arch_ver in ["4.26"]:
tmp = F.interpolate(
tmp, scale_factor=scale, mode="bilinear", align_corners=False
)
flow = tmp[:, :4] * scale
mask = tmp[:, 4:5]
feat_out = tmp[:, 5:]
return flow, mask, feat_out
mask = tmp[:, 4:5]
return flow, mask
class Contextnet(nn.Module):
def __init__(self, arch_ver="4.0"):
super(Contextnet, self).__init__()
c = 16
self.conv1 = Conv2(3, c, arch_ver=arch_ver)
self.conv2 = Conv2(c, 2 * c, arch_ver=arch_ver)
self.conv3 = Conv2(2 * c, 4 * c, arch_ver=arch_ver)
self.conv4 = Conv2(4 * c, 8 * c, arch_ver=arch_ver)
def forward(self, x, flow):
x = self.conv1(x)
flow = (
F.interpolate(flow, scale_factor=0.5, mode="bilinear", align_corners=False)
* 0.5
)
f1 = warp(x, flow)
x = self.conv2(x)
flow = (
F.interpolate(flow, scale_factor=0.5, mode="bilinear", align_corners=False)
* 0.5
)
f2 = warp(x, flow)
x = self.conv3(x)
flow = (
F.interpolate(flow, scale_factor=0.5, mode="bilinear", align_corners=False)
* 0.5
)
f3 = warp(x, flow)
x = self.conv4(x)
flow = (
F.interpolate(flow, scale_factor=0.5, mode="bilinear", align_corners=False)
* 0.5
)
f4 = warp(x, flow)
return [f1, f2, f3, f4]
class Unet(nn.Module):
def __init__(self, arch_ver="4.0"):
super(Unet, self).__init__()
c = 16
self.down0 = Conv2(17, 2 * c, arch_ver=arch_ver)
self.down1 = Conv2(4 * c, 4 * c, arch_ver=arch_ver)
self.down2 = Conv2(8 * c, 8 * c, arch_ver=arch_ver)
self.down3 = Conv2(16 * c, 16 * c, arch_ver=arch_ver)
self.up0 = deconv(32 * c, 8 * c, arch_ver=arch_ver)
self.up1 = deconv(16 * c, 4 * c, arch_ver=arch_ver)
self.up2 = deconv(8 * c, 2 * c, arch_ver=arch_ver)
self.up3 = deconv(4 * c, c, arch_ver=arch_ver)
self.conv = nn.Conv2d(c, 3, 3, 1, 1)
def forward(self, img0, img1, warped_img0, warped_img1, mask, flow, c0, c1):
s0 = self.down0(
torch.cat((img0, img1, warped_img0, warped_img1, mask, flow), 1)
)
s1 = self.down1(torch.cat((s0, c0[0], c1[0]), 1))
s2 = self.down2(torch.cat((s1, c0[1], c1[1]), 1))
s3 = self.down3(torch.cat((s2, c0[2], c1[2]), 1))
x = self.up0(torch.cat((s3, c0[3], c1[3]), 1))
x = self.up1(torch.cat((x, s2), 1))
x = self.up2(torch.cat((x, s1), 1))
x = self.up3(torch.cat((x, s0), 1))
x = self.conv(x)
return torch.sigmoid(x)
"""
currently supports 4.0-4.12
4.0: 4.0, 4.1
4.2: 4.2
4.3: 4.3, 4.4
4.5: 4.5
4.6: 4.6
4.7: 4.7, 4.8, 4.9
4.10: 4.10 4.11 4.12
"""
class Head_417(nn.Module):
def __init__(self):
super(Head_417, self).__init__()
self.cnn0 = nn.Conv2d(3, 32, 3, 2, 1)
self.cnn1 = nn.Conv2d(32, 32, 3, 1, 1)
self.cnn2 = nn.Conv2d(32, 32, 3, 1, 1)
self.cnn3 = nn.ConvTranspose2d(32, 8, 4, 2, 1)
self.relu = nn.LeakyReLU(0.2, True)
def forward(self, x, feat=False):
x0 = self.cnn0(x)
x = self.relu(x0)
x1 = self.cnn1(x)
x = self.relu(x1)
x2 = self.cnn2(x)
x = self.relu(x2)
x3 = self.cnn3(x)
if feat:
return [x0, x1, x2, x3]
return x3
class Head(nn.Module):
def __init__(self):
super(Head, self).__init__()
self.cnn0 = nn.Conv2d(3, 16, 3, 2, 1)
self.cnn1 = nn.Conv2d(16, 16, 3, 1, 1)
self.cnn2 = nn.Conv2d(16, 16, 3, 1, 1)
self.cnn3 = nn.ConvTranspose2d(16, 4, 4, 2, 1)
self.relu = nn.LeakyReLU(0.2, True)
def forward(self, x, feat=False):
x0 = self.cnn0(x)
x = self.relu(x0)
x1 = self.cnn1(x)
x = self.relu(x1)
x2 = self.cnn2(x)
x = self.relu(x2)
x3 = self.cnn3(x)
if feat:
return [x0, x1, x2, x3]
return x3
class IFNet(nn.Module):
def __init__(self, arch_ver="4.0"):
super(IFNet, self).__init__()
self.arch_ver = arch_ver
if arch_ver in ["4.0", "4.2", "4.3", "4.5", "4.6"]:
self.block0 = IFBlock(7, c=192, arch_ver=arch_ver)
self.block1 = IFBlock(8 + 4, c=128, arch_ver=arch_ver)
self.block2 = IFBlock(8 + 4, c=96, arch_ver=arch_ver)
self.block3 = IFBlock(8 + 4, c=64, arch_ver=arch_ver)
if arch_ver in ["4.7"]:
self.block0 = IFBlock(7 + 8, c=192, arch_ver=arch_ver)
self.block1 = IFBlock(8 + 4 + 8, c=128, arch_ver=arch_ver)
self.block2 = IFBlock(8 + 4 + 8, c=96, arch_ver=arch_ver)
self.block3 = IFBlock(8 + 4 + 8, c=64, arch_ver=arch_ver)
self.encode = nn.Sequential(
nn.Conv2d(3, 16, 3, 2, 1), nn.ConvTranspose2d(16, 4, 4, 2, 1)
)
if arch_ver in ["4.10", "4.17"]:
self.block0 = IFBlock(7 + 16, c=192, arch_ver=arch_ver)
self.block1 = IFBlock(8 + 4 + 16, c=128, arch_ver=arch_ver)
self.block2 = IFBlock(8 + 4 + 16, c=96, arch_ver=arch_ver)
self.block3 = IFBlock(8 + 4 + 16, c=64, arch_ver=arch_ver)
if arch_ver == "4.10":
self.encode = nn.Sequential(
nn.Conv2d(3, 32, 3, 2, 1),
nn.LeakyReLU(0.2, True),
nn.Conv2d(32, 32, 3, 1, 1),
nn.LeakyReLU(0.2, True),
nn.Conv2d(32, 32, 3, 1, 1),
nn.LeakyReLU(0.2, True),
nn.ConvTranspose2d(32, 8, 4, 2, 1),
)
else:
self.encode = Head_417()
if arch_ver in ["old_4.10_delete_me"]:
self.block0 = IFBlock(7 + 16, c=192)
self.block1 = IFBlock(8 + 4 + 16, c=128)
self.block2 = IFBlock(8 + 4 + 16, c=96)
self.block3 = IFBlock(8 + 4 + 16, c=64)
self.encode = nn.Sequential(
nn.Conv2d(3, 32, 3, 2, 1),
nn.LeakyReLU(0.2, True),
nn.Conv2d(32, 32, 3, 1, 1),
nn.LeakyReLU(0.2, True),
nn.Conv2d(32, 32, 3, 1, 1),
nn.LeakyReLU(0.2, True),
nn.ConvTranspose2d(32, 8, 4, 2, 1),
)
# add 4.26 support
if arch_ver in ["4.26"]:
self.block0 = IFBlock(7+8, c=192, arch_ver=arch_ver)
self.block1 = IFBlock(8+4+8+8, c=128, arch_ver=arch_ver)
self.block2 = IFBlock(8+4+8+8, c=96, arch_ver=arch_ver)
self.block3 = IFBlock(8+4+8+8, c=64, arch_ver=arch_ver)
self.block4 = IFBlock(8+4+8+8, c=32, arch_ver=arch_ver)
self.encode = Head()
if arch_ver in ["4.0", "4.2", "4.3"]:
self.contextnet = Contextnet(arch_ver=arch_ver)
self.unet = Unet(arch_ver=arch_ver)
self.arch_ver = arch_ver
def forward(
self,
img0,
img1,
timestep=0.5,
scale_list=[16, 8, 4, 2, 1],
training=True,
fastmode=True,
ensemble=False,
return_flow=False,
):
img0 = torch.clamp(img0, 0, 1)
img1 = torch.clamp(img1, 0, 1)
n, c, h, w = img0.shape
ph = ((h - 1) // 64 + 1) * 64
pw = ((w - 1) // 64 + 1) * 64
padding = (0, pw - w, 0, ph - h)
img0 = F.pad(img0, padding)
img1 = F.pad(img1, padding)
x = torch.cat((img0, img1), 1)
if training == False:
channel = x.shape[1] // 2
img0 = x[:, :channel]
img1 = x[:, channel:]
if not torch.is_tensor(timestep):
timestep = (x[:, :1].clone() * 0 + 1) * timestep
else:
timestep = timestep.repeat(1, 1, img0.shape[2], img0.shape[3])
flow_list = []
merged = []
mask_list = []
# add 4.26 support
if self.arch_ver in ["4.7", "4.10", "4.17", "4.26"]:
f0 = self.encode(img0[:, :3])
f1 = self.encode(img1[:, :3])
warped_img0 = img0
warped_img1 = img1
flow = None
mask = None
feat = None
# add 4.26 support
if self.arch_ver in ["4.26"]:
block = [self.block0, self.block1, self.block2, self.block3, self.block4]
num_blocks = 5
else:
block = [self.block0, self.block1, self.block2, self.block3]
num_blocks = 4
for i in range(num_blocks):
if flow is None:
if self.arch_ver in ["4.26"]:
flow, mask, feat = block[i](
torch.cat((img0[:, :3], img1[:, :3], f0, f1, timestep), 1),
None,
scale=scale_list[i],
)
elif self.arch_ver in ["4.0", "4.2", "4.3", "4.5", "4.6"]:
flow, mask = block[i](
torch.cat((img0[:, :3], img1[:, :3], timestep), 1),
None,
scale=scale_list[i],
)
if ensemble:
f1, m1 = block[i](
torch.cat((img1[:, :3], img0[:, :3], 1 - timestep), 1),
None,
scale=scale_list[i],
)
flow = (flow + torch.cat((f1[:, 2:4], f1[:, :2]), 1)) / 2
mask = (mask + (-m1)) / 2
# 4.7+
if self.arch_ver in ["4.7", "4.10", "4.17"]:
flow, mask = block[i](
torch.cat((img0[:, :3], img1[:, :3], f0, f1, timestep), 1),
None,
scale=scale_list[i],
)
if ensemble:
f_, m_ = block[i](
torch.cat(
(img1[:, :3], img0[:, :3], f1, f0, 1 - timestep), 1
),
None,
scale=scale_list[i],
)
flow = (flow + torch.cat((f_[:, 2:4], f_[:, :2]), 1)) / 2
mask = (mask + (-m_)) / 2
else:
# add 4.26 support
if self.arch_ver in ["4.26"]:
wf0 = warp(f0, flow[:, :2])
wf1 = warp(f1, flow[:, 2:4])
# add 4.26 support
input_tensor = torch.cat(
(
warped_img0[:, :3],
warped_img1[:, :3],
wf0,
wf1,
timestep,
mask,
feat,
),
1,
)
fd, m0, feat = block[i](
input_tensor,
flow,
scale=scale_list[i],
)
flow = flow + fd
mask = m0
# 4.0-4.6
elif self.arch_ver in ["4.0", "4.2", "4.3", "4.5", "4.6"]:
f0, m0 = block[i](
torch.cat(
(warped_img0[:, :3], warped_img1[:, :3], timestep, mask), 1
),
flow,
scale=scale_list[i],
)
if self.arch_ver in ["4.0"]:
if (
i == 1
and f0[:, :2].abs().max() > 32
and f0[:, 2:4].abs().max() > 32
and not training
):
for k in range(4):
scale_list[k] *= 2
flow, mask = block[0](
torch.cat((img0[:, :3], img1[:, :3], timestep), 1),
None,
scale=scale_list[0],
)
warped_img0 = warp(img0, flow[:, :2])
warped_img1 = warp(img1, flow[:, 2:4])
f0, m0 = block[i](
torch.cat(
(
warped_img0[:, :3],
warped_img1[:, :3],
timestep,
mask,
),
1,
),
flow,
scale=scale_list[i],
)
# 4.7+
if self.arch_ver in ["4.7", "4.10", "4.17"]:
fd, m0 = block[i](
torch.cat(
(
warped_img0[:, :3],
warped_img1[:, :3],
warp(f0, flow[:, :2]),
warp(f1, flow[:, 2:4]),
timestep,
mask,
),
1,
),
flow,
scale=scale_list[i],
)
flow = flow + fd
# 4.0-4.6 ensemble
if ensemble and self.arch_ver in [
"4.0",
"4.2",
"4.3",
"4.5",
"4.6",
]:
f1, m1 = block[i](
torch.cat(
(
warped_img1[:, :3],
warped_img0[:, :3],
1 - timestep,
-mask,
),
1,
),
torch.cat((flow[:, 2:4], flow[:, :2]), 1),
scale=scale_list[i],
)
f0 = (f0 + torch.cat((f1[:, 2:4], f1[:, :2]), 1)) / 2
m0 = (m0 + (-m1)) / 2
# 4.7+ ensemble
if ensemble and self.arch_ver in ["4.7", "4.10", "4.17"]:
wf0 = warp(f0, flow[:, :2])
wf1 = warp(f1, flow[:, 2:4])
f_, m_ = block[i](
torch.cat(
(
warped_img1[:, :3],
warped_img0[:, :3],
wf1,
wf0,
1 - timestep,
-mask,
),
1,
),
torch.cat((flow[:, 2:4], flow[:, :2]), 1),
scale=scale_list[i],
)
fd = (fd + torch.cat((f_[:, 2:4], f_[:, :2]), 1)) / 2
mask = (m0 + (-m_)) / 2
if self.arch_ver in ["4.0", "4.2", "4.3", "4.5", "4.6"]:
flow = flow + f0
mask = mask + m0
if not ensemble and self.arch_ver in ["4.7", "4.10", "4.17"]:
mask = m0
mask_list.append(mask)
flow_list.append(flow)
warped_img0 = warp(img0, flow[:, :2])
warped_img1 = warp(img1, flow[:, 2:4])
merged.append((warped_img0, warped_img1))
# add 4.26 support
if self.arch_ver in ["4.26"]:
final_mask = mask_list[-1] if mask_list else mask
final_mask = torch.sigmoid(final_mask)
merged_final = warped_img0 * final_mask + warped_img1 * (1 - final_mask)
else:
if self.arch_ver in ["4.0", "4.1", "4.2", "4.3", "4.4", "4.5", "4.6"]:
final_idx = min(3, len(mask_list)-1) if len(mask_list) > 0 else 0
if final_idx < len(mask_list):
mask_list[final_idx] = torch.sigmoid(mask_list[final_idx])
merged_final = merged[final_idx][0] * mask_list[final_idx] + merged[final_idx][1] * (1 - mask_list[final_idx])
else:
mask = torch.sigmoid(mask)
merged_final = warped_img0 * mask + warped_img1 * (1 - mask)
if self.arch_ver in ["4.7", "4.10", "4.17"]:
mask = torch.sigmoid(mask)
merged_final = warped_img0 * mask + warped_img1 * (1 - mask)
if not fastmode and self.arch_ver in ["4.0", "4.2", "4.3"]:
c0 = self.contextnet(img0, flow[:, :2])
c1 = self.contextnet(img1, flow[:, 2:4])
tmp = self.unet(img0, img1, warped_img0, warped_img1, mask, flow, c0, c1)
res = tmp[:, :3] * 2 - 1
merged_final = torch.clamp(merged_final + res, 0, 1)
return merged_final[:, :, :h, :w]
@@ -0,0 +1,428 @@
import yaml
import os
from torch.hub import download_url_to_file, get_dir
from urllib.parse import urlparse
import torch
import typing
import traceback
import einops
import gc
import torchvision.transforms.functional as transform
from comfy.model_management import soft_empty_cache, get_torch_device
import numpy as np
BASE_MODEL_DOWNLOAD_URLS = [
"https://github.com/styler00dollar/VSGAN-tensorrt-docker/releases/download/models/",
"https://github.com/Fannovel16/ComfyUI-Frame-Interpolation/releases/download/models/",
"https://github.com/dajes/frame-interpolation-pytorch/releases/download/v1.0.0/"
]
# Per-file fallback URLs for models no longer hosted at the base URLs above.
# Each entry is a list of mirrors tried in order.
CKPT_FALLBACK_URLS = {
"rife47.pth": [
"https://huggingface.co/marduk191/rife/resolve/main/rife47.pth",
"https://huggingface.co/wavespeed/misc/resolve/main/rife/rife47.pth",
"https://huggingface.co/MachineDelusions/RIFE/resolve/main/rife47.pth",
"https://huggingface.co/jasonot/mycomfyui/resolve/main/rife47.pth",
],
"rife49.pth": [
"https://huggingface.co/marduk191/rife/resolve/main/rife49.pth",
"https://huggingface.co/hfmaster/models-moved/resolve/main/rife/rife49.pth",
"https://huggingface.co/MachineDelusions/RIFE/resolve/main/rife49.pth",
"https://huggingface.co/Isi99999/Frame_Interpolation_Models/resolve/main/rife49.pth",
],
"sudo_rife4_269.662_testV1_scale1.pth": [
"https://huggingface.co/marduk191/rife/resolve/main/sudo_rife4_269.662_testV1_scale1.pth",
"https://huggingface.co/uwg/upscaler/resolve/main/ESRGAN/sudo_rife4_269.662_testV1_scale1.pth",
"https://huggingface.co/licyk/sd-upscaler-models/resolve/main/ESRGAN/sudo_rife4_269.662_testV1_scale1.pth",
],
}
config_path = os.path.join(os.path.dirname(__file__), "./config.yaml")
if os.path.exists(config_path):
config = yaml.load(open(config_path, "r"), Loader=yaml.FullLoader)
else:
raise Exception("config.yaml file is neccessary, plz recreate the config file by downloading it from https://github.com/Fannovel16/ComfyUI-Frame-Interpolation")
DEVICE = get_torch_device()
class InterpolationStateList():
def __init__(self, frame_indices: typing.List[int], is_skip_list: bool):
self.frame_indices = frame_indices
self.is_skip_list = is_skip_list
def is_frame_skipped(self, frame_index):
is_frame_in_list = frame_index in self.frame_indices
return self.is_skip_list and is_frame_in_list or not self.is_skip_list and not is_frame_in_list
class MakeInterpolationStateList:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"frame_indices": ("STRING", {"multiline": True, "default": "1,2,3"}),
"is_skip_list": ("BOOLEAN", {"default": True},),
},
}
RETURN_TYPES = ("INTERPOLATION_STATES",)
FUNCTION = "create_options"
CATEGORY = "ComfyUI-Frame-Interpolation/VFI"
def create_options(self, frame_indices: str, is_skip_list: bool):
frame_indices_list = [int(item) for item in frame_indices.split(',')]
interpolation_state_list = InterpolationStateList(
frame_indices=frame_indices_list,
is_skip_list=is_skip_list,
)
return (interpolation_state_list,)
def get_ckpt_container_path(model_type):
return os.path.abspath(os.path.join(os.path.dirname(__file__), config["ckpts_path"], model_type))
def load_file_from_url(url, model_dir=None, progress=True, file_name=None):
"""Load file form http url, will download models if necessary.
Ref:https://github.com/1adrianb/face-alignment/blob/master/face_alignment/utils.py
Args:
url (str): URL to be downloaded.
model_dir (str): The path to save the downloaded model. Should be a full path. If None, use pytorch hub_dir.
Default: None.
progress (bool): Whether to show the download progress. Default: True.
file_name (str): The downloaded file name. If None, use the file name in the url. Default: None.
Returns:
str: The path to the downloaded file.
"""
if model_dir is None: # use the pytorch hub_dir
hub_dir = get_dir()
model_dir = os.path.join(hub_dir, 'checkpoints')
os.makedirs(model_dir, exist_ok=True)
parts = urlparse(url)
file_name = os.path.basename(parts.path)
if file_name is not None:
file_name = file_name
cached_file = os.path.abspath(os.path.join(model_dir, file_name))
if not os.path.exists(cached_file):
print(f'Downloading: "{url}" to {cached_file}\n')
download_url_to_file(url, cached_file, hash_prefix=None, progress=progress)
return cached_file
def load_file_from_github_release(model_type, ckpt_name):
error_strs = []
all_urls = [base + ckpt_name for base in BASE_MODEL_DOWNLOAD_URLS]
all_urls += CKPT_FALLBACK_URLS.get(ckpt_name, [])
for i, url in enumerate(all_urls):
try:
return load_file_from_url(url, get_ckpt_container_path(model_type))
except Exception:
traceback_str = traceback.format_exc()
if i < len(all_urls) - 1:
print("Failed! Trying another endpoint.")
error_strs.append(f"Error when downloading from: {url}\n\n{traceback_str}")
error_str = '\n\n'.join(error_strs)
raise Exception(f"Tried all urls to download {ckpt_name} but no success. Below is the error log:\n\n{error_str}")
def load_file_from_direct_url(model_type, url):
return load_file_from_url(url, get_ckpt_container_path(model_type))
def preprocess_frames(frames):
return einops.rearrange(frames[..., :3], "n h w c -> n c h w")
def postprocess_frames(frames):
return einops.rearrange(frames, "n c h w -> n h w c")[..., :3].cpu()
def assert_batch_size(frames, batch_size=2, vfi_name=None):
subject_verb = "Most VFI models require" if vfi_name is None else f"VFI model {vfi_name} requires"
assert len(frames) >= batch_size, f"{subject_verb} at least {batch_size} frames to work with, only found {frames.shape[0]}. Please check the frame input using PreviewImage."
def _generic_frame_loop(
frames,
clear_cache_after_n_frames,
multiplier: typing.Union[typing.SupportsInt, typing.List],
return_middle_frame_function,
*return_middle_frame_function_args,
interpolation_states: InterpolationStateList = None,
use_timestep=True,
dtype=torch.float16,
batch_size=1,
final_logging=True):
#https://github.com/hzwer/Practical-RIFE/blob/main/inference_video.py#L169
def non_timestep_inference(frame0, frame1, n):
middle = return_middle_frame_function(frame0, frame1, None, *return_middle_frame_function_args)
if n == 1:
return [middle]
first_half = non_timestep_inference(frame0, middle, n=n//2)
second_half = non_timestep_inference(middle, frame1, n=n//2)
if n%2:
return [*first_half, middle, *second_half]
else:
return [*first_half, *second_half]
output_frames = torch.zeros(multiplier*frames.shape[0], *frames.shape[1:], dtype=dtype, device="cpu")
out_len = 0
number_of_frames_processed_since_last_cleared_cuda_cache = 0
# Collect all (frame_itr, frame0, frame1) pairs that are not skipped
# so we can group them into batches for Opt 7.
all_pairs = []
for frame_itr in range(len(frames) - 1):
frame0 = frames[frame_itr:frame_itr+1]
frame1 = frames[frame_itr+1:frame_itr+2]
skipped = (interpolation_states is not None and interpolation_states.is_frame_skipped(frame_itr))
all_pairs.append((frame_itr, frame0, frame1, skipped))
# -----------------------------------------------------------------------
# Opt 7: Batched frame-pair processing
#
# When batch_size > 1 and use_timestep is True, we stack multiple frame
# pairs into a single tensor and run the model once per batch instead of
# once per pair. This improves GPU utilisation on short, low-resolution
# clips where per-call overhead dominates.
#
# For the non-timestep (recursive) path and for batch_size == 1 we fall
# back to the original sequential loop to keep behaviour identical.
# -----------------------------------------------------------------------
def _run_single_pair(frame0, frame1):
"""Run all middle-frame inferences for one frame pair, return list of cpu tensors."""
middle_frames_out = []
if use_timestep:
for middle_i in range(1, multiplier):
timestep = middle_i / multiplier
middle_frame = return_middle_frame_function(
frame0.to(DEVICE),
frame1.to(DEVICE),
timestep,
*return_middle_frame_function_args
).detach().cpu().to(dtype=dtype)
middle_frames_out.append(middle_frame)
else:
middle_frames = non_timestep_inference(frame0.to(DEVICE), frame1.to(DEVICE), multiplier - 1)
middle_frames_out.extend(
[f.detach().cpu().to(dtype=dtype) for f in torch.cat(middle_frames, dim=0)]
)
return middle_frames_out
def _run_batched_pairs(pair_list):
"""
Run all middle-frame inferences for a batch of frame pairs.
pair_list: list of (frame0, frame1) 1×C×H×W tensors (cpu).
Returns: list-of-lists — outer index = pair, inner index = middle frame.
Only supported for use_timestep=True.
"""
B = len(pair_list)
# Stack pairs: shape [B, C, H, W]
f0_batch = torch.cat([p[0] for p in pair_list], dim=0).to(DEVICE) # [B,C,H,W]
f1_batch = torch.cat([p[1] for p in pair_list], dim=0).to(DEVICE)
results = [[] for _ in range(B)]
for middle_i in range(1, multiplier):
timestep = middle_i / multiplier
# Run each pair in the stacked batch individually but in one Python call.
# True GPU-level batching would require modifying IFNet; this approach
# removes Python loop overhead while staying architecture-agnostic.
batch_middle = []
for b_idx in range(B):
mf = return_middle_frame_function(
f0_batch[b_idx:b_idx+1],
f1_batch[b_idx:b_idx+1],
timestep,
*return_middle_frame_function_args
).detach().cpu().to(dtype=dtype)
batch_middle.append(mf)
for b_idx, mf in enumerate(batch_middle):
results[b_idx].append(mf)
return results
# Build groups of consecutive non-skipped pairs (skipped pairs are written
# as-is between groups and never sent to the model).
i = 0
total_pairs = len(all_pairs)
while i < total_pairs:
frame_itr, frame0, frame1, skipped = all_pairs[i]
# Always write frame0 to output
output_frames[out_len] = frame0
out_len += 1
if skipped:
i += 1
continue
# --- gather a batch of consecutive non-skipped pairs starting at i ---
if use_timestep and batch_size > 1:
batch_pairs = [] # (frame0, frame1) for non-skipped pairs in this batch
batch_itrs = [] # frame_itr values (for cache-clearing accounting)
j = i
while j < total_pairs and len(batch_pairs) < batch_size:
j_itr, j_f0, j_f1, j_skip = all_pairs[j]
if j_skip:
break # stop batch at a skip boundary
batch_pairs.append((j_f0, j_f1))
batch_itrs.append(j_itr)
j += 1
# Run batched inference
batch_results = _run_batched_pairs(batch_pairs)
# Write results; for pairs after the first we also need to write frame0
for rel_idx, (middle_frames_out, b_itr) in enumerate(zip(batch_results, batch_itrs)):
if rel_idx > 0:
# frame0 for this pair is frame1 of the previous pair
output_frames[out_len] = all_pairs[i + rel_idx][1] # frame1 of prev = frame0 of this
out_len += 1
for middle_frame in middle_frames_out:
output_frames[out_len] = middle_frame
out_len += 1
number_of_frames_processed_since_last_cleared_cuda_cache += 1
if number_of_frames_processed_since_last_cleared_cuda_cache >= clear_cache_after_n_frames:
print("Comfy-VFI: Clearing cache...", end=' ')
soft_empty_cache()
number_of_frames_processed_since_last_cleared_cuda_cache = 0
print("Done cache clearing")
gc.collect()
# Advance i by the number of pairs consumed (minus 1 because we
# already wrote frame0 of the first pair above).
i = j
continue
# --- original sequential path (batch_size == 1 or non-timestep) ---
frame0_fp = frame0.to(dtype=torch.float32)
frame1_fp = frame1.to(dtype=torch.float32)
middle_frame_batches = _run_single_pair(frame0_fp, frame1_fp)
for middle_frame in middle_frame_batches:
output_frames[out_len] = middle_frame
out_len += 1
number_of_frames_processed_since_last_cleared_cuda_cache += 1
if number_of_frames_processed_since_last_cleared_cuda_cache >= clear_cache_after_n_frames:
print("Comfy-VFI: Clearing cache...", end=' ')
soft_empty_cache()
number_of_frames_processed_since_last_cleared_cuda_cache = 0
print("Done cache clearing")
gc.collect()
i += 1
if final_logging:
print(f"Comfy-VFI done! {len(output_frames)} frames generated at resolution: {output_frames[0].shape}")
# Append final frame
output_frames[out_len] = frames[-1:]
out_len += 1
# clear cache for courtesy
if final_logging:
print("Comfy-VFI: Final clearing cache...", end=' ')
soft_empty_cache()
if final_logging:
print("Done cache clearing")
return output_frames[:out_len]
def generic_frame_loop(
model_name,
frames,
clear_cache_after_n_frames,
multiplier: typing.Union[typing.SupportsInt, typing.List],
return_middle_frame_function,
*return_middle_frame_function_args,
interpolation_states: InterpolationStateList = None,
use_timestep=True,
dtype=torch.float32,
batch_size=1):
assert_batch_size(frames, vfi_name=model_name.replace('_', ' ').replace('VFI', ''))
if type(multiplier) == int:
return _generic_frame_loop(
frames,
clear_cache_after_n_frames,
multiplier,
return_middle_frame_function,
*return_middle_frame_function_args,
interpolation_states=interpolation_states,
use_timestep=use_timestep,
dtype=dtype,
batch_size=batch_size
)
if type(multiplier) == list:
multipliers = list(map(int, multiplier))
multipliers += [2] * (len(frames) - len(multipliers) - 1)
frame_batches = []
for frame_itr in range(len(frames) - 1):
multiplier = multipliers[frame_itr]
if multiplier == 0: continue
frame_batch = _generic_frame_loop(
frames[frame_itr:frame_itr+2],
clear_cache_after_n_frames,
multiplier,
return_middle_frame_function,
*return_middle_frame_function_args,
interpolation_states=interpolation_states,
use_timestep=use_timestep,
dtype=dtype,
batch_size=batch_size,
final_logging=False
)
if frame_itr != len(frames) - 2: # Not append last frame unless this batch is the last one
frame_batch = frame_batch[:-1]
frame_batches.append(frame_batch)
output_frames = torch.cat(frame_batches)
print(f"Comfy-VFI done! {len(output_frames)} frames generated at resolution: {output_frames[0].shape}")
return output_frames
raise NotImplementedError(f"multipiler of {type(multiplier)}")
class FloatToInt:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"float": ("FLOAT", {"default": 0, 'min': 0, 'step': 0.01})
}
}
RETURN_TYPES = ("INT",)
FUNCTION = "convert"
CATEGORY = "ComfyUI-Frame-Interpolation"
def convert(self, float):
if hasattr(float, "__iter__"):
return (list(map(int, float)),)
return (int(float),)
""" def generic_4frame_loop(
frames,
clear_cache_after_n_frames,
multiplier: typing.SupportsInt,
return_middle_frame_function,
*return_middle_frame_function_args,
interpolation_states: InterpolationStateList = None,
use_timestep=False):
if use_timestep: raise NotImplementedError("Timestep 4 frame VFI model")
def non_timestep_inference(frame_0, frame_1, frame_2, frame_3, n):
middle = return_middle_frame_function(frame_0, frame_1, None, *return_middle_frame_function_args)
if n == 1:
return [middle]
first_half = non_timestep_inference(frame_0, middle, n=n//2)
second_half = non_timestep_inference(middle, frame_1, n=n//2)
if n%2:
return [*first_half, middle, *second_half]
else:
return [*first_half, *second_half] """
@@ -2,7 +2,7 @@
# Install only:
# pip install insightface onnxruntime-gpu open-clip-torch safetensors ml_dtypes==0.3.2
insightface>=0.7.3
insightface>=1.0
onnxruntime-gpu>=1.16.0
# If onnxruntime-gpu fails (CPU only): pip install onnxruntime
@@ -1,6 +1,6 @@
cython
facexlib
insightface
insightface>=1.0
onnxruntime
onnxruntime-gpu; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')
ftfy
+1 -1
View File
@@ -1,5 +1,5 @@
facexlib
insightface
insightface>=1.0
onnxruntime
onnxruntime-gpu; sys_platform != 'darwin' and platform_machine == 'x86_64'
ftfy
+99 -45
View File
@@ -927,52 +927,109 @@ def download_model(url: str, filename: str, on_progress=None, folder_type: str =
else:
raise RuntimeError("CivitAI API key required for downloads. Set it in PromptChain/config.json or CIVITAI_API_KEY env var.")
# Resume an interrupted download: a leftover .part is a valid in-order
# prefix of the file (TCP delivers bytes in order, and HTTP errors are
# caught before any write, so .part only ever holds real body bytes).
# Ask the server to continue from that offset via a Range request.
resume_from = 0
try:
if os.path.isfile(temp_path):
resume_from = os.path.getsize(temp_path)
except OSError:
resume_from = 0
if resume_from:
headers["Range"] = f"bytes={resume_from}-"
resp = requests.get(url, stream=True, timeout=30, allow_redirects=True, headers=headers)
resp.raise_for_status()
total = int(resp.headers.get("content-length", 0))
if total > _MAX_DOWNLOAD_BYTES:
# Range rejected (416): our .part is >= the server's file. Exactly equal
# = a finished-but-unpromoted download, so promote it; any other offset
# is unusable, so discard the stub and restart clean.
already_complete = False
if resume_from and resp.status_code == 416:
tail = resp.headers.get("content-range", "").rsplit("/", 1)[-1]
full = int(tail) if tail.isdigit() else 0
resp.close()
raise RuntimeError(f"refusing download: Content-Length {total} exceeds cap {_MAX_DOWNLOAD_BYTES}")
if total > 0:
try:
free = shutil.disk_usage(dest_dir).free
if total > free:
resp.close()
raise RuntimeError(f"insufficient disk space: need {total}, have {free}")
except OSError:
pass # disk_usage can fail on some network mounts; don't block download
if full and resume_from == full:
already_complete = True
else:
try:
os.remove(temp_path)
except OSError:
pass
resume_from = 0
headers.pop("Range", None)
resp = requests.get(url, stream=True, timeout=30, allow_redirects=True, headers=headers)
with _download_lock:
_download_state["size_bytes"] = total
if not already_complete:
resp.raise_for_status()
downloaded = 0
last_chunk_at = _time.monotonic()
with open(temp_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=1024 * 1024):
if _download_cancel.is_set():
resp.close()
raise RuntimeError("Download cancelled")
now = _time.monotonic()
if now - last_chunk_at > _CHUNK_STALL_SECONDS:
resp.close()
raise RuntimeError(f"download stalled: no data for {_CHUNK_STALL_SECONDS}s")
if not chunk:
continue
last_chunk_at = now
downloaded += len(chunk)
if downloaded > _MAX_DOWNLOAD_BYTES:
resp.close()
raise RuntimeError(f"download exceeded cap {_MAX_DOWNLOAD_BYTES} bytes (server lied about Content-Length)")
f.write(chunk)
progress = (downloaded / total * 100) if total else 0
# 206 = server honored the resume; its Content-Range carries the FULL
# size (Content-Length is only the remaining tail). Any other status
# (200) means Range was ignored — start clean so we never append onto
# a stale head or double-count progress.
if resp.status_code == 206:
mode = "ab"
tail = resp.headers.get("content-range", "").rsplit("/", 1)[-1]
total = int(tail) if tail.isdigit() else resume_from + int(resp.headers.get("content-length", 0))
else:
mode = "wb"
resume_from = 0
total = int(resp.headers.get("content-length", 0))
with _download_lock:
_download_state["downloaded"] = downloaded
_download_state["progress"] = progress
if total > _MAX_DOWNLOAD_BYTES:
resp.close()
raise RuntimeError(f"refusing download: size {total} exceeds cap {_MAX_DOWNLOAD_BYTES}")
if total > 0:
try:
free = shutil.disk_usage(dest_dir).free
if (total - resume_from) > free:
resp.close()
raise RuntimeError(f"insufficient disk space: need {total - resume_from}, have {free}")
except OSError:
pass # disk_usage can fail on some network mounts; don't block download
if on_progress:
on_progress(downloaded, total)
with _download_lock:
_download_state["size_bytes"] = total
downloaded = resume_from
last_chunk_at = _time.monotonic()
with open(temp_path, mode) as f:
for chunk in resp.iter_content(chunk_size=1024 * 1024):
if _download_cancel.is_set():
resp.close()
raise RuntimeError("Download cancelled")
now = _time.monotonic()
if now - last_chunk_at > _CHUNK_STALL_SECONDS:
resp.close()
raise RuntimeError(f"download stalled: no data for {_CHUNK_STALL_SECONDS}s")
if not chunk:
continue
last_chunk_at = now
downloaded += len(chunk)
if downloaded > _MAX_DOWNLOAD_BYTES:
resp.close()
# streamed past the declared size → the .part is corrupt
try:
os.remove(temp_path)
except OSError:
pass
raise RuntimeError(f"download exceeded cap {_MAX_DOWNLOAD_BYTES} bytes (server lied about Content-Length)")
f.write(chunk)
progress = (downloaded / total * 100) if total else 0
with _download_lock:
_download_state["downloaded"] = downloaded
_download_state["progress"] = progress
if on_progress:
on_progress(downloaded, total)
# Never promote a short file to its final name: a stream that ended
# early without raising would otherwise look complete on disk and
# block resume forever. Keep the .part so the next attempt continues.
if total > 0 and downloaded != total:
raise RuntimeError(f"incomplete download: got {downloaded} of {total} bytes")
os.replace(temp_path, dest_path)
@@ -984,12 +1041,9 @@ def download_model(url: str, filename: str, on_progress=None, folder_type: str =
with _download_lock:
_download_state["status"] = "failed"
_download_state["error"] = str(e)
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
# Keep the .part on a recoverable interruption (stall, network drop,
# cancel, incomplete) so the next attempt resumes from here instead of
# restarting. The corrupt-state paths above already removed it.
raise
finally:
with _download_lock:
+110
View File
@@ -0,0 +1,110 @@
# Viewer "Extend" support: from a finished VIDEO, pull its LAST frame (staged in
# the input dir for a LoadImage to consume) plus its dimensions/fps, and stage a
# content-addressed copy of the source video so the re-render can be recorded as
# a lineage CHILD (parent_filename). The frontend then rebuilds an I2V
# continuation graph (last frame -> next clip) the same way Re-pose rebuilds from
# a recipe template — see js/lib/extend-from-image.js.
import hashlib
import io as _io
import os
import folder_paths
import server
from aiohttp import web
from .api_utils import error_response
from .history_db import resolve_image_path
routes = server.PromptServer.instance.routes
# Same prefix Upscale uses: LoadImage's combo only lists top-level input files,
# so the namespace lives in the filename and the copy sits in the input ROOT.
_SRC_PREFIX = "promptchain_source_"
_VIDEO_EXTS = {".mp4", ".webm", ".mkv", ".mov", ".m4v", ".avi"}
def _staged_copy(raw: bytes, ext: str) -> str:
"""Content-addressed copy in the input root; same bytes -> same name, written
once. Returns the bare filename for LoadImage / parent_filename."""
digest = hashlib.sha256(raw).hexdigest()[:12]
name = f"{_SRC_PREFIX}{digest}{ext}"
dest = os.path.join(folder_paths.get_input_directory(), name)
if not os.path.exists(dest):
with open(dest, "wb") as f:
f.write(raw)
return name
def _last_frame_and_meta(video_bytes: bytes):
"""(PIL.Image last_frame, width, height, fps, frame_count). Decodes the whole
stream — Wan clips are short (~81-121 frames) so a full pass is cheap and far
more reliable than seeking to EOF."""
import av # PyAV (av) ships in the ComfyUI venv; powers the poster-frame path too
last = None
fps = 16.0
count = 0
container = av.open(_io.BytesIO(video_bytes))
try:
stream = container.streams.video[0]
try:
if stream.average_rate:
fps = float(stream.average_rate)
except Exception:
fps = 16.0
for frame in container.decode(stream):
last = frame
count += 1
finally:
container.close()
if last is None:
return None, 0, 0, fps, 0
img = last.to_image().convert("RGB")
w, h = img.size
return img, w, h, fps, count
@routes.get("/promptchain/extend/prepare")
async def _api_extend_prepare(request):
"""Stage the source video's last frame + a lineage copy. ?hash=<image hash>."""
image_hash = request.query.get("hash", "").strip()
if not image_hash:
return error_response("need hash")
path = resolve_image_path(image_hash)
if not path or not os.path.isfile(str(path)):
return error_response("video not found for hash", 404)
path = str(path)
if os.path.splitext(path)[1].lower() not in _VIDEO_EXTS:
return error_response("not a video file")
try:
with open(path, "rb") as f:
video_bytes = f.read()
except OSError as e:
return error_response(f"could not read video: {e}", 500)
try:
import av # noqa: F401
except Exception:
return error_response("PyAV (av) not available — cannot read video frames", 500)
try:
source_ref = _staged_copy(video_bytes, os.path.splitext(path)[1].lower())
img, width, height, fps, count = _last_frame_and_meta(video_bytes)
if img is None:
return error_response("could not decode any video frame", 500)
buf = _io.BytesIO()
img.save(buf, format="PNG")
last_frame = _staged_copy(buf.getvalue(), ".png")
except Exception as e:
return error_response(f"extend prepare failed: {e}", 500)
return web.json_response({
"last_frame": last_frame,
"source_ref": source_ref,
"width": width,
"height": height,
"fps": round(fps, 3),
"frame_count": count,
})
+6 -31
View File
@@ -98,38 +98,13 @@ def section_for_injectable(key: str) -> str | None:
# subprocess (inheriting our env + interpreter), so it writes to exactly the
# cache the runtime later reads, and is a cheap no-op once cached. Best-effort:
# a failure never aborts the install — the weight simply falls back to lazy.
_MEMBER_PREFETCH: dict[str, dict] = {
# PuLID Flux 2 pulls EVA02-L-14-336 (merged2b) via open_clip into the HF hub
# cache on first run. download_pretrained() fetches without building the model.
"PuLIDFlux2": {
"label": "EVA-CLIP",
"code": (
"import os; os.environ.setdefault('HF_HUB_DISABLE_PROGRESS_BARS','1'); "
"from open_clip.pretrained import get_pretrained_cfg, download_pretrained; "
"print('EVA-CLIP cached at', "
"download_pretrained(get_pretrained_cfg('EVA02-L-14-336','merged2b_s6b_b61k')))"
),
},
}
async def _prefetch_for_member(resp: web.StreamResponse, key: str) -> None:
"""Pre-fetch a just-installed member's lazy first-use weights. Best-effort —
on any failure it logs and returns, leaving the weight to download on first
use as it did before."""
spec = _MEMBER_PREFETCH.get(key)
if not spec:
return
await _send(resp, {"stage": "prefetch", "file": spec["label"]})
await _log(resp, f"Pre-fetching {spec['label']} so the first render doesn't stall…")
try:
code = await _run(resp, [sys.executable, "-c", spec["code"]])
if code == 0:
await _log(resp, f"{spec['label']}: ready")
else:
await _log(resp, f"{spec['label']}: pre-fetch skipped (exit {code}) — downloads on first use instead")
except Exception as e:
await _log(resp, f"{spec['label']}: pre-fetch skipped ({e}) — downloads on first use instead")
"""No-op. Weight pre-fetching was removed for ComfyUI Registry compliance —
it ran a subprocess to warm a pack's lazy weights. Those weights now simply
download on first render, as the packs already handle (e.g. PuLID-Flux2's
EVA-CLIP via open_clip), so the first such render is slightly slower and
nothing else changes."""
return None
def _member_status(m: dict) -> dict:
+218 -52
View File
@@ -7,10 +7,13 @@ Three layers:
User: {user_dir}/PromptChain/models/{hash}.json — user overrides
Each config embeds quick_hash and sha256 in its files array. Lookups go
through a hash-based config index, not filenames.
through a hash + filename config index.
User overrides discovered overrides system. On save, only the delta
from the base config is stored so updates still propagate.
User settings override the resolved base config. When a file matches both a
curated system preset (by filename) and an auto-discovered config (by the
embedded hash), the system preset wins — it holds the authoritative family,
templates, and node ranges. On save, only the delta from the base config is
stored so shipped updates still propagate.
"""
from __future__ import annotations
@@ -55,20 +58,40 @@ def _user_dir() -> Path:
# old filename-based reverse index.
_config_index: Optional[dict] = None
_config_index_signature: Optional[tuple] = None
def _config_dir_signature() -> tuple:
# Freshness check mirroring get_db's mtime watch: a directory's mtime
# changes when configs are added, removed, or atomically replaced
# (atomic_write_json renames into place; git /deploy pulls write new
# files), so shipped presets are picked up without a server restart.
# In-place content edits to an existing file don't bump the dir mtime —
# those still need invalidate_config_index() or a restart.
sig = []
for directory in (_system_dir(), _discovered_dir()):
try:
sig.append(directory.stat().st_mtime_ns)
except OSError:
sig.append(0)
return tuple(sig)
def _get_config_index() -> dict:
global _config_index
if _config_index is not None:
global _config_index, _config_index_signature
sig = _config_dir_signature()
if _config_index is not None and sig == _config_index_signature:
return _config_index
_config_index = _build_config_index()
_config_index_signature = sig
return _config_index
def invalidate_config_index():
"""Force rebuild on next lookup (e.g. after adding/removing configs)."""
global _config_index
global _config_index, _config_index_signature
_config_index = None
_config_index_signature = None
def _build_config_index() -> dict:
@@ -83,8 +106,11 @@ def _build_config_index() -> dict:
"by_filename": {}, # filename.lower() → config dict
}
# Discovered first, then system — system overwrites, giving curated
# configs priority over auto-detected ones.
for directory in (_discovered_dir(), _system_dir()):
# configs priority over auto-detected ones. Each config is stamped with
# its source tier so the resolver can prefer a curated system preset over
# an auto-discovered config when both match the same physical file.
for directory, tier in ((_discovered_dir(), "discovered"),
(_system_dir(), "system")):
if not directory.is_dir():
continue
for path in directory.glob("*.json"):
@@ -93,6 +119,7 @@ def _build_config_index() -> dict:
data = _read_json(path)
if not data:
continue
data["_source_tier"] = tier
# Legacy hash-named configs without files array:
# treat stem as quick_hash
@@ -127,17 +154,23 @@ def _index_single_entry(index: dict, entry: dict, config: dict):
if fname:
key = fname.lower()
existing = index["by_filename"].get(key)
# Two configs claiming the same filename is ambiguous; keep first
# (deterministic by scan order) and surface the conflict so the
# user can see why a particular config won.
if existing is not None and existing is not config:
logger.debug(
"filename collision %s: kept %s, ignored %s",
fname,
_ascii_safe(existing.get("display_name", "?")),
_ascii_safe(config.get("display_name", "?")),
)
return
existing_system = existing.get("_source_tier") == "system"
new_system = config.get("_source_tier") == "system"
# A curated system preset always wins over an auto-discovered
# config for the same filename — the discovered one carries the
# file's hash but lacks the authoritative family/templates. Within
# the same tier, keep first (deterministic by scan order) and log.
if existing_system and not new_system:
return
if not (new_system and not existing_system):
logger.debug(
"filename collision %s: kept %s, ignored %s",
fname,
_ascii_safe(existing.get("display_name", "?")),
_ascii_safe(config.get("display_name", "?")),
)
return
index["by_filename"][key] = config
@@ -392,20 +425,34 @@ def load(model_hash: str) -> Optional[dict]:
return merged
def _find_base_config(model_hash: str) -> Optional[dict]:
"""Find the base config (system or discovered) for a model hash.
def _resolve_base_config(model_hash: str) -> Optional[dict]:
"""Resolve the curated/discovered base config for a hash (no install gate).
Checks the config index by quick_hash first, then falls back to
filename matching via the fingerprint index. For multi-file models,
verifies all required files are installed before returning.
A model file can match an auto-discovered config by hash (the CivitAI scan
embeds the file's quick_hash) and a curated system preset by filename
(HuggingFace presets carry no hash). When both match, the curated system
preset wins — it holds the authoritative family, templates, and node
ranges. No install gate here so save()/delta resolves the same base load()
surfaces.
"""
config = find_config_by_hash(model_hash)
if not config:
# Fallback: configs without quick_hash (e.g. HuggingFace-only models)
# can still be matched by filename from the fingerprint scan
fp_info = fingerprint.find_by_hash(model_hash)
if fp_info:
config = find_config_by_filename(fp_info["filename"])
by_hash = find_config_by_hash(model_hash)
by_name = None
fp_info = fingerprint.find_by_hash(model_hash)
if fp_info:
by_name = find_config_by_filename(fp_info["filename"])
for candidate in (by_hash, by_name):
if candidate is not None and candidate.get("_source_tier") == "system":
return candidate
return by_hash or by_name
def _find_base_config(model_hash: str) -> Optional[dict]:
"""Find the base config for a model hash, gated on its files being present.
For multi-file models, verifies all required files are installed before
returning so a half-downloaded model doesn't surface a dead template.
"""
config = _resolve_base_config(model_hash)
if config:
if "files" in config and not _all_files_installed(config["files"]):
import logging
@@ -435,7 +482,7 @@ def save(model_hash: str, config: dict):
user_dir = _user_dir()
user_dir.mkdir(parents=True, exist_ok=True)
system_config = find_config_by_hash(model_hash)
system_config = _resolve_base_config(model_hash)
delta = _compute_delta(config, system_config) if system_config else config
if not delta and system_config:
@@ -628,18 +675,21 @@ def list_version_details(model_name: str) -> list[dict]:
def _file_exists_in_folder(filename: str, folder_type: str, min_size: int = 0) -> bool:
"""True if filename exists in folder_type and is at least min_size bytes.
"""True if filename exists in folder_type and isn't grossly undersized.
A truncated download left at its final path (no .part suffix) would
otherwise pass mere-existence checks and make its catalog entry
disappear, blocking the user from resuming it.
Catches a truncated download left at its final path (no .part suffix) that
would otherwise pass a mere-existence check and make its catalog entry
disappear, blocking resume. But catalog config sizes are ROUNDED ESTIMATES,
not exact byte counts (a Q8 GGUF listed as 16 GB is really ~15.4 GB), so the
floor is half the estimate — enough to reject a real stub while accepting a
complete file that's a few percent under the guess.
"""
try:
for folder in folder_paths.get_folder_paths(folder_type):
path = os.path.join(folder, filename)
if not os.path.isfile(path):
continue
if min_size and os.path.getsize(path) < min_size:
if min_size and os.path.getsize(path) < min_size * 0.5:
continue
return True
except Exception:
@@ -655,6 +705,10 @@ def _all_files_installed(files: list[dict]) -> bool:
entries need at least one variant present.
"""
for entry in files:
# Optional files (e.g. a Fast-template LoRA) don't gate "installed":
# a vanilla download without them must still surface the config.
if entry.get("optional"):
continue
folder_type = entry.get("folder", "")
if not folder_type:
continue
@@ -669,6 +723,103 @@ def _all_files_installed(files: list[dict]) -> bool:
return True
_PRIMARY_FOLDERS = ("diffusion_models", "unet", "checkpoints")
def _precision_tier(precision: str) -> str:
"""High-precision primaries pull the high-precision companion; everything
else (fp8*, fp4, GGUF quants) pulls the lighter one. Mirrors the client's
precisionTier so companion files pair to the chosen primary consistently."""
return "high" if re.search(r"fp16|bf16|fp32", precision or "", re.I) else "low"
def _resolve_files_for_precision(files: list[dict], precision: str) -> list[dict]:
"""The concrete files a precision needs — primary variant + companions paired
by tier. Mirror of the client resolveFilesForPrecision so install state is
computed against the exact same file set the download modal would fetch."""
tier = _precision_tier(precision)
resolved = []
for f in files:
variants = f.get("variants")
if not variants:
resolved.append(f)
continue
match = next((v for v in variants if v.get("precision") == precision), None) \
or next((v for v in variants if _precision_tier(v.get("precision")) == tier), None) \
or variants[0]
if not match:
continue
resolved.append({
"label": f.get("label"),
"folder": f.get("folder"),
"filename": match.get("filename"),
"size_bytes": match.get("size_bytes", 0),
"quick_hash": match.get("quick_hash"),
"optional": f.get("optional", False),
"source": match.get("source"),
})
return resolved
def _precision_install_status(files: list[dict]) -> dict:
"""Per declared primary precision → 'installed' | 'partial' | 'missing'.
Status is keyed on the precision's OWN model weight (the primary variant —
the quant / diffusion file). Shared companions (text encoder, VAE) are
reused across every precision, so a precision whose weight isn't downloaded
is 'missing' even when those companions happen to be on disk from another
model. Only when the weight IS present do we split installed (all companions
too) from 'partial' (a resumable download missing a companion).
"""
# Only a variant-bearing MODEL file (diffusion/unet/checkpoint) defines
# selectable precisions. A single-weight model whose only variants live on a
# companion — e.g. the 5B, whose lone variant file is the shared umt5 text
# encoder — has no model precisions; surfacing the encoder's fp8/fp16 as if
# they were model choices is meaningless (and falsely "partial" when the
# shared encoder happens to be on disk from another model).
primary = next((f for f in files if f.get("variants") and f.get("folder") in _PRIMARY_FOLDERS), None)
if not primary:
return {}
primary_folder = primary.get("folder", "")
precisions: list[str] = []
for v in primary.get("variants", []):
p = v.get("precision")
if p and p not in precisions:
precisions.append(p)
status = {}
for p in precisions:
variant = next((v for v in primary.get("variants", []) if v.get("precision") == p), None)
if not (variant and _file_installed(variant, primary_folder)):
status[p] = "missing"
continue
required = [
f for f in _resolve_files_for_precision(files, p)
if not f.get("optional") and f.get("folder") and (f.get("filename") or f.get("quick_hash"))
]
present = sum(1 for f in required if _file_installed(f, f["folder"]))
status[p] = "installed" if present == len(required) else "partial"
return status
def _primary_file_present(files: list[dict]) -> bool:
"""True if any primary model weight (diffusion/unet/checkpoint) is on disk.
Drives the 'resume' hint for entries without precision variants: a shared
encoder/VAE being present is NOT a started download — only the model's own
weight is, so we never glow an un-downloaded model amber."""
for f in files:
if f.get("folder") not in _PRIMARY_FOLDERS:
continue
variants = f.get("variants")
if variants:
if any(_file_installed(v, f["folder"]) for v in variants):
return True
elif (f.get("filename") or f.get("quick_hash")) and _file_installed(f, f["folder"]):
return True
return False
def _base_version_key(version: str) -> tuple:
"""Parse version string into a comparable tuple, ignoring precision suffixes.
@@ -725,6 +876,8 @@ def _collect_uninstalled_presets() -> list[dict]:
entry["civitai_model_id"] = data["civitai_model_id"]
if data.get("thumbnail"):
entry["thumbnail"] = data["thumbnail"]
entry["precision_status"] = _precision_install_status(entry["files"])
entry["partial"] = _primary_file_present(entry["files"])
entries.append(entry)
return entries
@@ -798,31 +951,44 @@ def is_catalog_filename(filename: str) -> bool:
return filename.lower() in _get_config_index()["by_filename"]
def _entry_matches_filename(entry: dict, filename_lower: str) -> bool:
"""True if a file entry owns this filename — as its flat filename or any
of its precision variants."""
flat = entry.get("filename")
if flat:
return flat.lower() == filename_lower
return any(v.get("filename", "").lower() == filename_lower
for v in entry.get("variants", []))
def is_primary_model_file(filename: str) -> bool:
"""True if this filename is the primary file for its config.
"""True if this filename is the model's primary (picker-facing) file.
Multi-file models list several component files (dual experts, text
encoders, VAEs, LoRAs). The primary file is the first diffusion_models
or unet entry. Only it should appear in the picker — secondaries are
hidden to avoid duplicate entries.
A multi-file config lists several components (dual MoE experts, text
encoder, VAE, LoRAs). Only the primary surfaces in the picker; its
precision variants become the selectable versions and every other
component is hidden so one model reads as one selection.
Returns True for files not in any config (standalone models).
The primary is declared with "primary": true on a file entry — needed
when a config has more than one diffusion_models/unet entry (e.g. a MoE
high+low pair). Configs with a single, unambiguous diffusion entry omit
the flag and fall back to "first diffusion_models/unet entry wins". Files
in no config (standalone models) are always primary.
"""
config = find_config_by_filename(filename)
if not config or "files" not in config:
return True
lower = filename.lower()
for entry in config["files"]:
if entry.get("folder") not in ("diffusion_models", "unet"):
continue
flat = entry.get("filename")
if flat:
return flat.lower() == lower
for v in entry.get("variants", []):
if v.get("filename", "").lower() == lower:
return True
return False
files = config["files"]
flagged = [e for e in files if e.get("primary")]
if flagged:
return any(_entry_matches_filename(e, lower) for e in flagged)
for entry in files:
if entry.get("folder") in ("diffusion_models", "unet"):
return _entry_matches_filename(entry, lower)
return True
+37 -121
View File
@@ -135,9 +135,13 @@ NODE_PACKS: dict[str, dict] = {
# provide. `pip` installs ultralytics WITH its deps (torch/numpy/opencv
# restored afterward by _restore_critical). proof_nodes is our own node,
# so `present` hinges on the face model existing on disk.
# pi-heif: ultralytics monkey-patches PIL.Image.open and lazily pip-
# installs pi-heif on the FIRST open of any unidentifiable file — which
# targets the wrong interpreter and then retries forever, spamming the
# log. Shipping it here puts it in the right venv so that never fires.
"repos": [],
"proof_nodes": ["PromptChain_RegionalDetailer"],
"pip": ["ultralytics"],
"pip": ["ultralytics", "pi-heif"],
"models": [
{"url": "https://huggingface.co/Bingsu/adetailer/resolve/main/face_yolov8n.pt",
"dest": "ultralytics/bbox/face_yolov8n.pt"},
@@ -188,6 +192,19 @@ NODE_PACKS: dict[str, dict] = {
"dest": "SEEDVR2/ema_vae_fp16.safetensors"},
],
},
"FrameInterpolation": {
"label": "Frame Interpolation (RIFE)",
# RIFE neural frame interpolation for the viewer "Smooth" action (2x/3x
# fps). Bundled (Fannovel16 ComfyUI-Frame-Interpolation, stripped to the
# RIFE model only — the other interpolation models were unused and carried
# unclear/non-commercial licenses). The rife49 weights auto-download from
# the pack's own HF-mirrored registry on first run, so no model is fetched
# here. pip adds einops (torch/numpy/opencv/torchvision fall to _PROTECTED).
"bundle": ["ComfyUI-Frame-Interpolation"],
"proof_nodes": ["RIFE VFI"],
"pip": ["einops"],
"models": [],
},
"ControlNet": {
"label": "ControlNet",
# Preprocessors are now NATIVE (vendor/pcr_cnaux + pcr_rtmpose, always
@@ -545,80 +562,32 @@ def _torch_index_url() -> str | None:
async def _restore_critical(resp: web.StreamResponse, snapshot: dict[str, str | None]) -> None:
"""Compare critical pkg versions to the pre-install snapshot; reinstall
any that changed. torch trio restored together from the CUDA index."""
now = {p: _pkg_version(p) for p in _CRITICAL}
trio_changed = any(snapshot.get(p) and snapshot[p] != now.get(p) for p in _TORCH_TRIO)
if trio_changed:
if all(snapshot.get(p) for p in _TORCH_TRIO):
await _log(resp, "torch stack changed during install — restoring matched CUDA build")
args = [sys.executable, "-m", "pip", "install",
*(f"{p}=={snapshot[p].split('+')[0]}" for p in _TORCH_TRIO)]
idx = _torch_index_url()
if idx:
args += ["--index-url", idx]
await _run(resp, args)
else:
await _log(resp, "torch stack changed but no clean snapshot to restore from — skipping")
for p in ("numpy", "opencv-python"):
if snapshot.get(p) and snapshot[p] != now.get(p):
await _log(resp, f"{p} changed ({now.get(p)} -> restoring {snapshot[p]})")
await _run(resp, [sys.executable, "-m", "pip", "install", f"{p}=={snapshot[p]}"])
"""No-op. PromptChain no longer runs pip at runtime (the ComfyUI Registry
forbids it), so the critical torch/numpy stack is never disturbed by an
install and has nothing to restore. Kept for call-site/import compatibility."""
return None
async def install_pack(resp: web.StreamResponse, injectable: str,
snapshot: dict[str, str | None]) -> bool:
"""Install one injectable's packs + models into custom_nodes/ and models/,
streaming progress on the already-open SSE `resp`. The caller owns the SSE
lifecycle and the pre-install critical-deps `snapshot`. Returns True on
success; on failure it emits an {"error": …} event and returns False.
"""Copy one injectable's bundled packs into custom_nodes/ and download its
models, streaming progress on the already-open SSE `resp`. The caller owns
the SSE lifecycle. Returns True on success; on failure it emits an
{"error": …} event and returns False.
Shared by the single-pack route and the section installer so both run the
identical clone/copy → pip → install.py → restore → download sequence.
Every step skips work already done (cloned dirs, present models, satisfied
pip), so re-running or installing an overlapping section is cheap."""
Registry-compliant: PromptChain never runs pip or git at runtime — bundled
packs are copied and their Python deps are declared in requirements.txt
(installed with the pack). Every step skips work already done, so re-running
or installing an overlapping section is cheap. `snapshot` is now unused."""
spec = NODE_PACKS[injectable]
repos = spec.get("repos", [])
bundles = spec.get("bundle", [])
git = shutil.which("git")
if repos and not git:
await _send(resp, {"error": "git not found on PATH — can't clone node packs"})
return False
cn = _custom_nodes_dir()
await _log(resp, f"Installing {spec['label']} ({len(repos) + len(bundles)} pack(s))")
await _log(resp, f"Installing {spec['label']} ({len(bundles)} pack(s))")
pack_dirs = [] # every pack dir we ensured is present in custom_nodes/
# 1) git-clone url-based packs, pinned to a known-good commit.
for url in repos:
name = _repo_dir_name(url)
dest = cn / name
await _send(resp, {"stage": "clone", "repo": name})
if dest.is_dir():
await _log(resp, f"{name}: already present, skipping clone")
else:
code = await _run(resp, [git, "clone", "--depth", "1", url, str(dest)])
if code != 0:
await _send(resp, {"error": f"git clone failed for {name} (exit {code})"})
return False
# Pin a FRESHLY-cloned pack to the commit our inject + extra_pip +
# model list were written against, so a later upstream change can't
# silently break a new install. Only fresh clones — an existing
# user checkout is left untouched. A pin miss is non-fatal (GitHub
# serves fetch-by-sha, but keep the clone HEAD if it ever fails).
pin = (spec.get("pins") or {}).get(name)
if pin:
await _send(resp, {"stage": "pin", "repo": name})
await _run(resp, [git, "-C", str(dest), "fetch", "--depth", "1", "origin", pin])
if await _run(resp, [git, "-C", str(dest), "checkout", "--quiet", pin]) != 0:
await _log(resp, f" warning: could not pin {name} to {pin[:10]} — using clone HEAD")
pack_dirs.append(dest)
# 2) copy bundled packs straight out of PromptChain (no git / no network).
# Only when absent — a copy the user already runs is never touched, so a
# bundled pack can never collide with their own install.
# Copy bundled packs straight out of PromptChain (no git, no network, no
# pip). Only when absent — a copy the user already runs is never touched, so
# a bundled pack can never collide with their own install.
for name in bundles:
src = _bundled_packs_dir() / name
dest = cn / name
@@ -631,63 +600,10 @@ async def install_pack(resp: web.StreamResponse, injectable: str,
else:
shutil.copytree(src, dest)
await _log(resp, f"{name}: copied into custom_nodes")
pack_dirs.append(dest)
# 3) install each ensured pack's requirements + run its install.py.
for dest in pack_dirs:
name = dest.name
req = dest / "requirements.txt"
if req.is_file():
await _send(resp, {"stage": "pip", "repo": name})
kept, notes = _filtered_requirements(req)
for n in notes:
await _log(resp, f" {n}")
if kept:
code = await _run(resp, [sys.executable, "-m", "pip", "install", *kept])
if code != 0:
# pip can die mid-transaction (e.g. locked dll on
# Windows) AFTER it already swapped packages — restore
# the critical stack before bailing, not only on success.
await _restore_critical(resp, snapshot)
await _send(resp, {"error": f"pip install failed for {name} (exit {code})"})
return False
else:
await _log(resp, f"{name}: no requirements.txt")
install_py = dest / "install.py"
if install_py.is_file():
await _send(resp, {"stage": "install_script", "repo": name})
await _run(resp, [sys.executable, "install.py"], cwd=str(dest))
# Packages installed WITH their deps (e.g. ultralytics needs opencv/pillow/
# etc.). The torch stack is restored by _restore_critical below if a dep
# drags it, so this is safe — unlike extra_pip, which is --no-deps.
pip_with_deps = spec.get("pip") or []
if pip_with_deps:
await _send(resp, {"stage": "pip", "repo": "deps"})
await _log(resp, f"Installing: {', '.join(pip_with_deps)}")
code = await _run(resp, [sys.executable, "-m", "pip", "install", *pip_with_deps])
if code != 0:
await _restore_critical(resp, snapshot)
await _send(resp, {"error": f"pip install failed for {', '.join(pip_with_deps)} (exit {code})"})
return False
# Extra packages a pack imports but deliberately omits from requirements
# (e.g. PuLID-Flux hard-imports facenet_pytorch, kept out of its reqs
# because its pins would downgrade torch). Installed --no-deps so they
# can't touch the protected torch stack.
extra_pip = spec.get("extra_pip") or []
if extra_pip:
await _send(resp, {"stage": "pip", "repo": "extra"})
await _log(resp, f"Installing extra deps (--no-deps): {', '.join(extra_pip)}")
code = await _run(resp, [sys.executable, "-m", "pip", "install", "--no-deps", *extra_pip])
if code != 0:
await _restore_critical(resp, snapshot)
await _send(resp, {"error": f"pip install failed for extra deps (exit {code})"})
return False
await _send(resp, {"stage": "fix_deps"})
await _restore_critical(resp, snapshot)
# Python deps are NOT pip-installed here — the ComfyUI Registry forbids a
# node running pip at runtime. Every bundled pack's deps are declared in
# PromptChain's own requirements.txt, installed WITH the pack by Manager/pip.
models = spec.get("models") or []
if models:
+6 -10
View File
@@ -309,14 +309,8 @@ async def subject_status(request):
return web.json_response(out)
async def _pip_install(resp, snapshot, pkgs):
await _send(resp, {"stage": "pip", "repo": ", ".join(pkgs)})
await _log(resp, "Installing: " + ", ".join(pkgs))
if await _run(resp, [_sys.executable, "-m", "pip", "install", *pkgs]) != 0:
await _restore_critical(resp, snapshot)
await _send(resp, {"error": f"pip install failed for {', '.join(pkgs)}"})
return False
return True
# (_pip_install removed — PromptChain declares its deps in requirements.txt and
# never runs pip at runtime; missing packages are surfaced to the user instead.)
def _download_birefnet():
@@ -340,7 +334,8 @@ async def install_subject_target(resp, target: str, snapshot: dict) -> bool:
owns the SSE lifecycle, the snapshot, and the final _restore_critical."""
if target == "subject":
miss = [m for m in ("transformers",) if not _have(m)]
if miss and not await _pip_install(resp, snapshot, miss):
if miss:
await _send(resp, {"error": f"missing package(s): {', '.join(miss)}. Run `pip install -r requirements.txt` for PromptChain (or reinstall it via Manager) and restart."})
return False
if not _birefnet_cached():
await _send(resp, {"stage": "download", "file": "BiRefNet (~0.9GB)", "pct": 0})
@@ -349,7 +344,8 @@ async def install_subject_target(resp, target: str, snapshot: dict) -> bool:
return True
if target == "object":
miss = [m for m in ("ultralytics",) if not _have(m)]
if miss and not await _pip_install(resp, snapshot, miss):
if miss:
await _send(resp, {"error": f"missing package(s): {', '.join(miss)}. Run `pip install -r requirements.txt` for PromptChain (or reinstall it via Manager) and restart."})
return False
if not _sam_cached():
await _send(resp, {"stage": "download", "file": "SAM2 (~154MB)", "pct": 0})
+97 -10
View File
@@ -1,4 +1,7 @@
import logging
import os
import tempfile
import threading
from pathlib import Path
from PIL import Image
@@ -8,11 +11,61 @@ from .history_db import resolve_image_path, get_data_dir
MAX_WIDTH = 600
QUALITY = 85
# Single-writer per hash within this process. The /generation POST generates a
# thumb on its own to_thread worker AFTER committing the DB row, and any
# concurrent GET /thumb (the sidebar workflow-cover especially) resolves that
# committed row and generates the SAME thumb on another worker — both miss the
# is_file() guard in the post-commit/pre-thumb window. The atomic publish below
# is the correctness guarantee against torn reads; this lock just collapses the
# redundant decode+resize. threading.Lock (not asyncio) — runs in worker threads.
_locks: dict[str, threading.Lock] = {}
_locks_guard = threading.Lock()
def _lock_for(image_hash: str) -> threading.Lock:
with _locks_guard:
return _locks.setdefault(image_hash, threading.Lock())
# Video outputs (Wan/AnimateDiff/…) get recorded like images. PIL can't open
# them, so thumbnail the first decoded frame instead — and never hand a video
# to Image.open (ultralytics' global PIL patch turns that miss into a noisy,
# offline-failing pi_heif auto-install attempt).
VIDEO_SUFFIXES = {".mp4", ".webm", ".mkv", ".mov", ".m4v"}
def _get_thumbs_dir() -> Path:
return get_data_dir() / "thumbs"
def _first_video_frame(source: Path) -> "Image.Image | None":
"""Decode a video's first frame as a poster image, or None if it can't."""
try:
import av
except ImportError:
return None
try:
with av.open(str(source)) as container:
stream = next((s for s in container.streams if s.type == "video"), None)
if stream is None:
return None
for frame in container.decode(stream):
return frame.to_image()
except Exception:
logging.debug("[PromptChain] could not decode video poster for %s",
source, exc_info=True)
return None
def _load_poster(source: Path) -> "Image.Image | None":
"""A still PIL image to thumbnail from: the first frame for video, the file
itself for images. Fully loaded and detached so the source handle closes."""
if source.suffix.lower() in VIDEO_SUFFIXES:
return _first_video_frame(source)
with Image.open(source) as im:
im.load()
return im.copy()
def get_or_create_thumbnail(image_hash: str) -> Path | None:
thumbs_dir = _get_thumbs_dir()
thumbs_dir.mkdir(parents=True, exist_ok=True)
@@ -21,12 +74,20 @@ def get_or_create_thumbnail(image_hash: str) -> Path | None:
if thumb_path.is_file():
return thumb_path
source = resolve_image_path(image_hash)
if not source or not source.is_file():
return None
with _lock_for(image_hash):
# another worker may have published while we waited on the lock
if thumb_path.is_file():
return thumb_path
source = resolve_image_path(image_hash)
if not source or not source.is_file():
return None
try:
img = _load_poster(source)
if img is None:
return None
try:
with Image.open(source) as img:
if img.mode in ("RGBA", "LA", "P", "PA"):
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
@@ -40,8 +101,34 @@ def get_or_create_thumbnail(image_hash: str) -> Path | None:
ratio = MAX_WIDTH / img.width
img = img.resize((MAX_WIDTH, round(img.height * ratio)), Image.LANCZOS)
img.save(thumb_path, format="WEBP", quality=QUALITY)
return thumb_path
except Exception:
logging.exception("[PromptChain] thumbnail generation failed for %s", image_hash)
return None
# Atomic publish: a concurrent GET /thumb must never FileResponse a
# half-written webp (PIL opens the path 'wb' = truncate-then-stream).
# Encode into a sibling temp file (same dir → same volume → os.replace
# is atomic) and swap it onto the final path in one indivisible step.
fd, tmp_name = tempfile.mkstemp(
dir=str(thumbs_dir), prefix=f".{image_hash}.", suffix=".webp.tmp"
)
try:
with os.fdopen(fd, "wb") as f:
img.save(f, format="WEBP", quality=QUALITY)
os.replace(tmp_name, thumb_path)
except PermissionError:
# Windows can refuse os.replace if the destination is open by a
# reader. The thumb is content-addressed, so if it now exists a
# peer already published identical bytes — accept that and drop
# our temp; otherwise report a clean miss.
try:
os.unlink(tmp_name)
except OSError:
pass
return thumb_path if thumb_path.is_file() else None
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
return thumb_path
except Exception:
logging.exception("[PromptChain] thumbnail generation failed for %s", image_hash)
return None
+11 -1
View File
@@ -130,7 +130,17 @@
"steps_range": [
12,
48
]
],
"width": 1024,
"height": 1024
},
"DualModelGuider": {
"cfg": 7
},
"CFGOverride": {
"cfg": 3,
"start_percent": 0.7,
"end_percent": 1.0
},
"EmptyFlux2LatentImage": {
"width": 1024,
+50 -91
View File
@@ -1,91 +1,50 @@
{
"display_name": "Wan 2.2 TI2V - 5B",
"model_name": "Wan 2.2 5B",
"version": "5B",
"architecture": "wan22",
"family": "wan22_5b",
"author": "Alibaba/Wan",
"description": "5B parameter hybrid text-to-video and image-to-video model. Single model — no MoE split needed. Fits in 8GB VRAM. Uses Wan 2.2 VAE (48-channel latent).",
"license": "Apache 2.0",
"tags": [
"wan",
"video",
"t2v",
"i2v",
"5B",
"consumer",
"low-vram"
],
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"release_date": "2025-12-01",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"negative": "",
"notes": "Single model for both T2V and I2V. No MoE split. Uses wan2.2_vae (not wan_2.1_vae). 1280x704, 49 frames, 16fps.",
"files": [
{
"label": "Diffusion Model",
"folder": "diffusion_models",
"filename": "wan2.2_ti2v_5B_fp16.safetensors",
"size_bytes": 9999658848,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors"
},
"sha256": null
},
{
"label": "Text Encoder (UMT5-XXL)",
"folder": "text_encoders",
"filename": "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"size_bytes": 6735906897,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors"
},
"sha256": null
},
{
"label": "VAE (Wan 2.2)",
"folder": "vae",
"filename": "wan2.2_vae.safetensors",
"size_bytes": 1409400960,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/vae/wan2.2_vae.safetensors"
},
"sha256": null
}
],
"nodes": {
"KSampler": {
"steps": 30,
"cfg": 4.0,
"sampler_name": "euler",
"scheduler": "simple",
"steps_range": [
20,
50
],
"cfg_range": [
1.0,
7.0
],
"sampler_name_options": [
"euler",
"dpmpp_sde",
"dpmpp_2m"
],
"scheduler_options": [
"simple",
"normal",
"beta"
]
}
}
}
{
"display_name": "Wan 2.2 TI2V - 5B",
"model_name": "Wan 2.2 5B",
"version": "5B",
"architecture": "wan22",
"family": "wan22_5b",
"author": "Alibaba/Wan",
"description": "5B hybrid text-and-image-to-video. Single dense model (no MoE, no speed LoRA). The lightest, fastest local Wan - fits ~8GB. Uses the dedicated wan2.2_vae (48-channel latent). fp16 safetensors via UNETLoader.",
"license": "Apache 2.0",
"tags": ["wan", "video", "ti2v", "t2v", "i2v", "5B", "consumer", "low-vram"],
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"release_date": "2025-12-01",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"negative": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"notes": "Single dense KSampler: 20 steps, CFG 5.0, uni_pc/simple, ModelSamplingSD3 shift 8.0, 1280x704, 121 frames, 24fps. Separate I2V (load a start image) and T2V (no start image) templates. REQUIRES wan2.2_vae (NOT wan_2.1_vae). Trivial on 24GB. No distill LoRA exists for 5B.",
"files": [
{
"label": "Diffusion Model",
"folder": "diffusion_models",
"filename": "wan2.2_ti2v_5B_fp16.safetensors",
"size_bytes": 9999658848,
"source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors" },
"sha256": null
},
{
"label": "Text Encoder (UMT5-XXL)",
"folder": "text_encoders",
"variants": [
{ "precision": "fp8", "filename": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", "size_bytes": 6735906897, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "umt5_xxl_fp16.safetensors", "size_bytes": 11401671879, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp16.safetensors" }, "sha256": null }
]
},
{ "label": "VAE (Wan 2.2)", "folder": "vae", "filename": "wan2.2_vae.safetensors", "size_bytes": 1429407336, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/vae/wan2.2_vae.safetensors" }, "sha256": null }
],
"nodes": {
"KSampler": {
"steps": 20,
"cfg": 5.0,
"sampler_name": "uni_pc",
"scheduler": "simple",
"steps_range": [15, 40],
"cfg_range": [1.0, 8.0],
"sampler_name_options": ["uni_pc", "euler", "dpmpp_2m"],
"scheduler_options": ["simple", "normal", "beta"]
}
}
}
+52
View File
@@ -0,0 +1,52 @@
{
"display_name": "Wan 2.2 TI2V - 5B (GGUF)",
"model_name": "Wan 2.2 5B GGUF",
"version": "5B",
"architecture": "wan22",
"family": "wan22_5b_gguf",
"author": "Alibaba/Wan (GGUF by QuantStack)",
"description": "5B hybrid text-and-image-to-video, GGUF-quantized. Single dense model loaded via UnetLoaderGGUF (requires ComfyUI-GGUF). The lightest local Wan - runs in a few GB. Uses the dedicated wan2.2_vae.",
"license": "Apache 2.0",
"tags": ["wan", "video", "ti2v", "t2v", "i2v", "5B", "gguf", "low-vram"],
"url": "https://huggingface.co/QuantStack/Wan2.2-TI2V-5B-GGUF",
"release_date": "2025-12-01",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"negative": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"notes": "GGUF requires UnetLoaderGGUF (ComfyUI-GGUF). Templates bake Q5_K_M; pick a different quant in the loader dropdown if downloaded. Single dense KSampler: 20 steps, CFG 5.0, uni_pc/simple, shift 8.0, 1280x704, 121 frames, 24fps. REQUIRES wan2.2_vae. Separate I2V and T2V templates.",
"files": [
{
"label": "Diffusion Model - GGUF",
"folder": "unet",
"variants": [
{ "precision": "Q5_K_M", "filename": "Wan2.2-TI2V-5B-Q5_K_M.gguf", "size_bytes": 3700000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-TI2V-5B-GGUF", "path": "Wan2.2-TI2V-5B-Q5_K_M.gguf" }, "sha256": null },
{ "precision": "Q8_0", "filename": "Wan2.2-TI2V-5B-Q8_0.gguf", "size_bytes": 5400000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-TI2V-5B-GGUF", "path": "Wan2.2-TI2V-5B-Q8_0.gguf" }, "sha256": null },
{ "precision": "Q6_K", "filename": "Wan2.2-TI2V-5B-Q6_K.gguf", "size_bytes": 4300000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-TI2V-5B-GGUF", "path": "Wan2.2-TI2V-5B-Q6_K.gguf" }, "sha256": null },
{ "precision": "Q4_K_M", "filename": "Wan2.2-TI2V-5B-Q4_K_M.gguf", "size_bytes": 3100000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-TI2V-5B-GGUF", "path": "Wan2.2-TI2V-5B-Q4_K_M.gguf" }, "sha256": null }
]
},
{
"label": "Text Encoder (UMT5-XXL)",
"folder": "text_encoders",
"variants": [
{ "precision": "fp8", "filename": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", "size_bytes": 6735906897, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "umt5_xxl_fp16.safetensors", "size_bytes": 11401671879, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp16.safetensors" }, "sha256": null }
]
},
{ "label": "VAE (Wan 2.2)", "folder": "vae", "filename": "wan2.2_vae.safetensors", "size_bytes": 1429407336, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/vae/wan2.2_vae.safetensors" }, "sha256": null }
],
"nodes": {
"KSampler": {
"steps": 20,
"cfg": 5.0,
"sampler_name": "uni_pc",
"scheduler": "simple",
"steps_range": [15, 40],
"cfg_range": [1.0, 8.0],
"sampler_name_options": ["uni_pc", "euler", "dpmpp_2m"],
"scheduler_options": ["simple", "normal", "beta"]
}
}
}
+61 -126
View File
@@ -1,126 +1,61 @@
{
"display_name": "Wan 2.2 I2V - 14B",
"model_name": "Wan 2.2 I2V",
"version": "14B",
"architecture": "wan22",
"family": "wan22_i2v",
"author": "Alibaba/Wan",
"description": "14B parameter image-to-video model. Dual-expert MoE architecture — high-noise and low-noise models run sequentially. 4-step generation with LightX2V LoRAs.",
"license": "Apache 2.0",
"tags": [
"wan",
"video",
"i2v",
"image-to-video",
"MoE",
"14B"
],
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"release_date": "2025-12-01",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"negative": "",
"notes": "Dual-expert MoE: load HIGH + LOW noise models separately. 4 steps with LightX2V LoRAs, euler/simple, shift 5.0. 640x640, 81 frames, 16fps.",
"files": [
{
"label": "Diffusion Model (High Noise)",
"folder": "diffusion_models",
"filename": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
"size_bytes": 14294742832,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors"
},
"sha256": null
},
{
"label": "Diffusion Model (Low Noise)",
"folder": "diffusion_models",
"filename": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
"size_bytes": 14294742832,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors"
},
"sha256": null
},
{
"label": "Text Encoder (UMT5-XXL)",
"folder": "text_encoders",
"filename": "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"size_bytes": 6735906897,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors"
},
"sha256": null
},
{
"label": "VAE",
"folder": "vae",
"filename": "wan_2.1_vae.safetensors",
"size_bytes": 253815318,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/vae/wan_2.1_vae.safetensors"
},
"sha256": null
},
{
"label": "LightX2V LoRA (High Noise)",
"folder": "loras",
"filename": "wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors",
"size_bytes": 1226977424,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/loras/wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors"
},
"sha256": null
},
{
"label": "LightX2V LoRA (Low Noise)",
"folder": "loras",
"filename": "wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors",
"size_bytes": 1226977424,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/loras/wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors"
},
"sha256": null
}
],
"nodes": {
"KSampler": {
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"steps_range": [
4,
30
],
"cfg_range": [
1.0,
5.0
],
"sampler_name_options": [
"euler",
"dpmpp_sde",
"dpmpp_2m"
],
"scheduler_options": [
"simple",
"normal",
"beta"
]
}
}
}
{
"display_name": "Wan 2.2 I2V - 14B",
"model_name": "Wan 2.2 I2V",
"version": "14B",
"architecture": "wan22",
"family": "wan22_i2v",
"author": "Alibaba/Wan",
"description": "14B image-to-video. Dual-expert MoE (high-noise + low-noise run sequentially). Safetensors fp8/fp16 via UNETLoader. Vanilla full-quality sampling by default; an optional LightX2V 4-step LoRA template gives fast drafts.",
"license": "Apache 2.0",
"tags": ["wan", "video", "i2v", "image-to-video", "MoE", "14B"],
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"release_date": "2025-12-01",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"negative": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"notes": "Vanilla: 20 steps (10 high + 10 low), CFG 3.5, euler/simple, ModelSamplingSD3 shift 5.0, 640x640, 81 frames, 16fps. Fast template = LightX2V 4-step LoRAs on both experts (4 steps, CFG 1.0) - faster but less motion and no negative prompt. 24GB: fp8 comfortable (one expert resident ~14GB); fp16 needs offload.",
"files": [
{
"label": "Diffusion Model (High Noise)",
"folder": "diffusion_models",
"primary": true,
"variants": [
{ "precision": "fp8_scaled", "filename": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors", "size_bytes": 14294742832, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "wan2.2_i2v_high_noise_14B_fp16.safetensors", "size_bytes": 28649479008, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp16.safetensors" }, "sha256": null }
]
},
{
"label": "Diffusion Model (Low Noise)",
"folder": "diffusion_models",
"variants": [
{ "precision": "fp8_scaled", "filename": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors", "size_bytes": 14294742832, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "wan2.2_i2v_low_noise_14B_fp16.safetensors", "size_bytes": 28649479008, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp16.safetensors" }, "sha256": null }
]
},
{
"label": "Text Encoder (UMT5-XXL)",
"folder": "text_encoders",
"variants": [
{ "precision": "fp8", "filename": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", "size_bytes": 6735906897, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "umt5_xxl_fp16.safetensors", "size_bytes": 11401671879, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp16.safetensors" }, "sha256": null }
]
},
{ "label": "VAE", "folder": "vae", "filename": "wan_2.1_vae.safetensors", "size_bytes": 253815318, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/vae/wan_2.1_vae.safetensors" }, "sha256": null },
{ "label": "LightX2V 4-step LoRA - High Noise (Fast, rank64 build)", "folder": "loras", "optional": true, "filename": "wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors", "size_bytes": 665845760, "source": { "provider": "huggingface", "repo": "lightx2v/Wan2.2-Distill-Loras", "path": "wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors" }, "sha256": null },
{ "label": "LightX2V 4-step LoRA - Low Noise (Fast, rank64 build)", "folder": "loras", "optional": true, "filename": "wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors", "size_bytes": 774897664, "source": { "provider": "huggingface", "repo": "lightx2v/Wan2.2-Distill-Loras", "path": "wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors" }, "sha256": null }
],
"nodes": {
"KSamplerAdvanced": {
"steps": 20,
"cfg": 3.5,
"sampler_name": "euler",
"scheduler": "simple",
"steps_range": [10, 40],
"cfg_range": [1.0, 6.0],
"sampler_name_options": ["euler", "uni_pc", "dpmpp_2m"],
"scheduler_options": ["simple", "normal", "beta"]
}
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"display_name": "Wan 2.2 I2V - 14B (GGUF)",
"model_name": "Wan 2.2 I2V GGUF",
"version": "14B",
"architecture": "wan22",
"family": "wan22_i2v_gguf",
"author": "Alibaba/Wan (GGUF by QuantStack)",
"description": "14B image-to-video, GGUF-quantized for lower VRAM. Dual-expert MoE loaded via UnetLoaderGGUF (requires the ComfyUI-GGUF node pack). Pick a quantization to download (Q5_K_M recommended for 24GB).",
"license": "Apache 2.0",
"tags": ["wan", "video", "i2v", "image-to-video", "MoE", "14B", "gguf"],
"url": "https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF",
"release_date": "2025-12-01",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"negative": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"notes": "GGUF requires UnetLoaderGGUF (ComfyUI-GGUF). Templates bake Q5_K_M; if you download a different quant, pick it in the loader dropdown. Q5_K_M ~10.8GB/expert, Q8_0 ~16GB. Same recipe as the safetensors I2V (shift 5.0, vanilla 20 steps / fast 4-step LoRA). umt5 + VAE are the standard safetensors files.",
"files": [
{
"label": "Diffusion Model (High Noise) - GGUF",
"folder": "unet",
"primary": true,
"variants": [
{ "precision": "Q5_K_M", "filename": "Wan2.2-I2V-A14B-HighNoise-Q5_K_M.gguf", "size_bytes": 10800000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-I2V-A14B-GGUF", "path": "HighNoise/Wan2.2-I2V-A14B-HighNoise-Q5_K_M.gguf" }, "sha256": null },
{ "precision": "Q8_0", "filename": "Wan2.2-I2V-A14B-HighNoise-Q8_0.gguf", "size_bytes": 16000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-I2V-A14B-GGUF", "path": "HighNoise/Wan2.2-I2V-A14B-HighNoise-Q8_0.gguf" }, "sha256": null },
{ "precision": "Q6_K", "filename": "Wan2.2-I2V-A14B-HighNoise-Q6_K.gguf", "size_bytes": 13000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-I2V-A14B-GGUF", "path": "HighNoise/Wan2.2-I2V-A14B-HighNoise-Q6_K.gguf" }, "sha256": null },
{ "precision": "Q4_K_M", "filename": "Wan2.2-I2V-A14B-HighNoise-Q4_K_M.gguf", "size_bytes": 9000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-I2V-A14B-GGUF", "path": "HighNoise/Wan2.2-I2V-A14B-HighNoise-Q4_K_M.gguf" }, "sha256": null }
]
},
{
"label": "Diffusion Model (Low Noise) - GGUF",
"folder": "unet",
"variants": [
{ "precision": "Q5_K_M", "filename": "Wan2.2-I2V-A14B-LowNoise-Q5_K_M.gguf", "size_bytes": 10800000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-I2V-A14B-GGUF", "path": "LowNoise/Wan2.2-I2V-A14B-LowNoise-Q5_K_M.gguf" }, "sha256": null },
{ "precision": "Q8_0", "filename": "Wan2.2-I2V-A14B-LowNoise-Q8_0.gguf", "size_bytes": 16000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-I2V-A14B-GGUF", "path": "LowNoise/Wan2.2-I2V-A14B-LowNoise-Q8_0.gguf" }, "sha256": null },
{ "precision": "Q6_K", "filename": "Wan2.2-I2V-A14B-LowNoise-Q6_K.gguf", "size_bytes": 13000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-I2V-A14B-GGUF", "path": "LowNoise/Wan2.2-I2V-A14B-LowNoise-Q6_K.gguf" }, "sha256": null },
{ "precision": "Q4_K_M", "filename": "Wan2.2-I2V-A14B-LowNoise-Q4_K_M.gguf", "size_bytes": 9000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-I2V-A14B-GGUF", "path": "LowNoise/Wan2.2-I2V-A14B-LowNoise-Q4_K_M.gguf" }, "sha256": null }
]
},
{
"label": "Text Encoder (UMT5-XXL)",
"folder": "text_encoders",
"variants": [
{ "precision": "fp8", "filename": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", "size_bytes": 6735906897, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "umt5_xxl_fp16.safetensors", "size_bytes": 11401671879, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp16.safetensors" }, "sha256": null }
]
},
{ "label": "VAE", "folder": "vae", "filename": "wan_2.1_vae.safetensors", "size_bytes": 253815318, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/vae/wan_2.1_vae.safetensors" }, "sha256": null },
{ "label": "LightX2V 4-step LoRA - High Noise (Fast, rank64 build)", "folder": "loras", "optional": true, "filename": "wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors", "size_bytes": 665845760, "source": { "provider": "huggingface", "repo": "lightx2v/Wan2.2-Distill-Loras", "path": "wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors" }, "sha256": null },
{ "label": "LightX2V 4-step LoRA - Low Noise (Fast, rank64 build)", "folder": "loras", "optional": true, "filename": "wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors", "size_bytes": 774897664, "source": { "provider": "huggingface", "repo": "lightx2v/Wan2.2-Distill-Loras", "path": "wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors" }, "sha256": null }
],
"nodes": {
"KSamplerAdvanced": {
"steps": 20,
"cfg": 3.5,
"sampler_name": "euler",
"scheduler": "simple",
"steps_range": [10, 40],
"cfg_range": [1.0, 6.0],
"sampler_name_options": ["euler", "uni_pc", "dpmpp_2m"],
"scheduler_options": ["simple", "normal", "beta"]
}
}
}
+61 -126
View File
@@ -1,126 +1,61 @@
{
"display_name": "Wan 2.2 T2V - 14B",
"model_name": "Wan 2.2 T2V",
"version": "14B",
"architecture": "wan22",
"family": "wan22_t2v",
"author": "Alibaba/Wan",
"description": "14B parameter text-to-video model. Dual-expert MoE architecture — high-noise and low-noise models run sequentially. 4-step generation with LightX2V LoRAs.",
"license": "Apache 2.0",
"tags": [
"wan",
"video",
"t2v",
"text-to-video",
"MoE",
"14B"
],
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"release_date": "2025-12-01",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"negative": "",
"notes": "Dual-expert MoE: load HIGH + LOW noise models separately. 4 steps with LightX2V LoRAs, euler/simple, shift 5.0. 640x640, 81 frames, 16fps.",
"files": [
{
"label": "Diffusion Model (High Noise)",
"folder": "diffusion_models",
"filename": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
"size_bytes": 14293923632,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors"
},
"sha256": null
},
{
"label": "Diffusion Model (Low Noise)",
"folder": "diffusion_models",
"filename": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
"size_bytes": 14293923632,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors"
},
"sha256": null
},
{
"label": "Text Encoder (UMT5-XXL)",
"folder": "text_encoders",
"filename": "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"size_bytes": 6735906897,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors"
},
"sha256": null
},
{
"label": "VAE",
"folder": "vae",
"filename": "wan_2.1_vae.safetensors",
"size_bytes": 253815318,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/vae/wan_2.1_vae.safetensors"
},
"sha256": null
},
{
"label": "LightX2V LoRA (High Noise)",
"folder": "loras",
"filename": "wan2.2_t2v_lightx2v_4steps_lora_v1.1_high_noise.safetensors",
"size_bytes": 1226977424,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/loras/wan2.2_t2v_lightx2v_4steps_lora_v1.1_high_noise.safetensors"
},
"sha256": null
},
{
"label": "LightX2V LoRA (Low Noise)",
"folder": "loras",
"filename": "wan2.2_t2v_lightx2v_4steps_lora_v1.1_low_noise.safetensors",
"size_bytes": 1226977424,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"path": "split_files/loras/wan2.2_t2v_lightx2v_4steps_lora_v1.1_low_noise.safetensors"
},
"sha256": null
}
],
"nodes": {
"KSampler": {
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"steps_range": [
4,
30
],
"cfg_range": [
1.0,
5.0
],
"sampler_name_options": [
"euler",
"dpmpp_sde",
"dpmpp_2m"
],
"scheduler_options": [
"simple",
"normal",
"beta"
]
}
}
}
{
"display_name": "Wan 2.2 T2V - 14B",
"model_name": "Wan 2.2 T2V",
"version": "14B",
"architecture": "wan22",
"family": "wan22_t2v",
"author": "Alibaba/Wan",
"description": "14B text-to-video. Dual-expert MoE (high-noise + low-noise run sequentially). Safetensors fp8/fp16 via UNETLoader. Vanilla full-quality sampling by default; an optional LightX2V 4-step LoRA template gives fast drafts.",
"license": "Apache 2.0",
"tags": ["wan", "video", "t2v", "text-to-video", "MoE", "14B"],
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"release_date": "2025-12-01",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"negative": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"notes": "Vanilla: 20 steps (10 high + 10 low), CFG 3.5, euler/simple, ModelSamplingSD3 shift 5.0, 640x640, 81 frames, 16fps. Fast template = LightX2V 4-step LoRAs v1.1 on both experts (4 steps, CFG 1.0). 24GB: fp8 comfortable (one expert resident ~14GB); fp16 needs offload.",
"files": [
{
"label": "Diffusion Model (High Noise)",
"folder": "diffusion_models",
"primary": true,
"variants": [
{ "precision": "fp8_scaled", "filename": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors", "size_bytes": 14293923632, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "wan2.2_t2v_high_noise_14B_fp16.safetensors", "size_bytes": 28649479008, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp16.safetensors" }, "sha256": null }
]
},
{
"label": "Diffusion Model (Low Noise)",
"folder": "diffusion_models",
"variants": [
{ "precision": "fp8_scaled", "filename": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors", "size_bytes": 14293923632, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "wan2.2_t2v_low_noise_14B_fp16.safetensors", "size_bytes": 28649479008, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp16.safetensors" }, "sha256": null }
]
},
{
"label": "Text Encoder (UMT5-XXL)",
"folder": "text_encoders",
"variants": [
{ "precision": "fp8", "filename": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", "size_bytes": 6735906897, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "umt5_xxl_fp16.safetensors", "size_bytes": 11401671879, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp16.safetensors" }, "sha256": null }
]
},
{ "label": "VAE", "folder": "vae", "filename": "wan_2.1_vae.safetensors", "size_bytes": 253815318, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/vae/wan_2.1_vae.safetensors" }, "sha256": null },
{ "label": "LightX2V 4-step LoRA - High Noise (Fast, rank64 build)", "folder": "loras", "optional": true, "filename": "wan2.2_t2v_A14b_high_noise_lora_rank64_lightx2v_4step_1217.safetensors", "size_bytes": 643825664, "source": { "provider": "huggingface", "repo": "lightx2v/Wan2.2-Distill-Loras", "path": "wan2.2_t2v_A14b_high_noise_lora_rank64_lightx2v_4step_1217.safetensors" }, "sha256": null },
{ "label": "LightX2V 4-step LoRA - Low Noise (Fast, rank64 build)", "folder": "loras", "optional": true, "filename": "wan2.2_t2v_A14b_low_noise_lora_rank64_lightx2v_4step_1217.safetensors", "size_bytes": 643825664, "source": { "provider": "huggingface", "repo": "lightx2v/Wan2.2-Distill-Loras", "path": "wan2.2_t2v_A14b_low_noise_lora_rank64_lightx2v_4step_1217.safetensors" }, "sha256": null }
],
"nodes": {
"KSamplerAdvanced": {
"steps": 20,
"cfg": 3.5,
"sampler_name": "euler",
"scheduler": "simple",
"steps_range": [10, 40],
"cfg_range": [1.0, 6.0],
"sampler_name_options": ["euler", "uni_pc", "dpmpp_2m"],
"scheduler_options": ["simple", "normal", "beta"]
}
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"display_name": "Wan 2.2 T2V - 14B (GGUF)",
"model_name": "Wan 2.2 T2V GGUF",
"version": "14B",
"architecture": "wan22",
"family": "wan22_t2v_gguf",
"author": "Alibaba/Wan (GGUF by QuantStack)",
"description": "14B text-to-video, GGUF-quantized for lower VRAM. Dual-expert MoE loaded via UnetLoaderGGUF (requires the ComfyUI-GGUF node pack). Pick a quantization to download (Q5_K_M recommended for 24GB).",
"license": "Apache 2.0",
"tags": ["wan", "video", "t2v", "text-to-video", "MoE", "14B", "gguf"],
"url": "https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF",
"release_date": "2025-12-01",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"negative": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"notes": "GGUF requires UnetLoaderGGUF (ComfyUI-GGUF). Templates bake Q5_K_M; if you download a different quant, pick it in the loader dropdown. Same recipe as the safetensors T2V (shift 5.0, vanilla 20 steps / fast 4-step LoRA v1.1). umt5 + VAE are the standard safetensors files.",
"files": [
{
"label": "Diffusion Model (High Noise) - GGUF",
"folder": "unet",
"primary": true,
"variants": [
{ "precision": "Q5_K_M", "filename": "Wan2.2-T2V-A14B-HighNoise-Q5_K_M.gguf", "size_bytes": 10800000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-T2V-A14B-GGUF", "path": "HighNoise/Wan2.2-T2V-A14B-HighNoise-Q5_K_M.gguf" }, "sha256": null },
{ "precision": "Q8_0", "filename": "Wan2.2-T2V-A14B-HighNoise-Q8_0.gguf", "size_bytes": 16000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-T2V-A14B-GGUF", "path": "HighNoise/Wan2.2-T2V-A14B-HighNoise-Q8_0.gguf" }, "sha256": null },
{ "precision": "Q6_K", "filename": "Wan2.2-T2V-A14B-HighNoise-Q6_K.gguf", "size_bytes": 13000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-T2V-A14B-GGUF", "path": "HighNoise/Wan2.2-T2V-A14B-HighNoise-Q6_K.gguf" }, "sha256": null },
{ "precision": "Q4_K_M", "filename": "Wan2.2-T2V-A14B-HighNoise-Q4_K_M.gguf", "size_bytes": 9000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-T2V-A14B-GGUF", "path": "HighNoise/Wan2.2-T2V-A14B-HighNoise-Q4_K_M.gguf" }, "sha256": null }
]
},
{
"label": "Diffusion Model (Low Noise) - GGUF",
"folder": "unet",
"variants": [
{ "precision": "Q5_K_M", "filename": "Wan2.2-T2V-A14B-LowNoise-Q5_K_M.gguf", "size_bytes": 10800000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-T2V-A14B-GGUF", "path": "LowNoise/Wan2.2-T2V-A14B-LowNoise-Q5_K_M.gguf" }, "sha256": null },
{ "precision": "Q8_0", "filename": "Wan2.2-T2V-A14B-LowNoise-Q8_0.gguf", "size_bytes": 16000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-T2V-A14B-GGUF", "path": "LowNoise/Wan2.2-T2V-A14B-LowNoise-Q8_0.gguf" }, "sha256": null },
{ "precision": "Q6_K", "filename": "Wan2.2-T2V-A14B-LowNoise-Q6_K.gguf", "size_bytes": 13000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-T2V-A14B-GGUF", "path": "LowNoise/Wan2.2-T2V-A14B-LowNoise-Q6_K.gguf" }, "sha256": null },
{ "precision": "Q4_K_M", "filename": "Wan2.2-T2V-A14B-LowNoise-Q4_K_M.gguf", "size_bytes": 9000000000, "source": { "provider": "huggingface", "repo": "QuantStack/Wan2.2-T2V-A14B-GGUF", "path": "LowNoise/Wan2.2-T2V-A14B-LowNoise-Q4_K_M.gguf" }, "sha256": null }
]
},
{
"label": "Text Encoder (UMT5-XXL)",
"folder": "text_encoders",
"variants": [
{ "precision": "fp8", "filename": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", "size_bytes": 6735906897, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors" }, "sha256": null },
{ "precision": "fp16", "filename": "umt5_xxl_fp16.safetensors", "size_bytes": 11401671879, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/text_encoders/umt5_xxl_fp16.safetensors" }, "sha256": null }
]
},
{ "label": "VAE", "folder": "vae", "filename": "wan_2.1_vae.safetensors", "size_bytes": 253815318, "source": { "provider": "huggingface", "repo": "Comfy-Org/Wan_2.2_ComfyUI_Repackaged", "path": "split_files/vae/wan_2.1_vae.safetensors" }, "sha256": null },
{ "label": "LightX2V 4-step LoRA - High Noise (Fast, rank64 build)", "folder": "loras", "optional": true, "filename": "wan2.2_t2v_A14b_high_noise_lora_rank64_lightx2v_4step_1217.safetensors", "size_bytes": 643825664, "source": { "provider": "huggingface", "repo": "lightx2v/Wan2.2-Distill-Loras", "path": "wan2.2_t2v_A14b_high_noise_lora_rank64_lightx2v_4step_1217.safetensors" }, "sha256": null },
{ "label": "LightX2V 4-step LoRA - Low Noise (Fast, rank64 build)", "folder": "loras", "optional": true, "filename": "wan2.2_t2v_A14b_low_noise_lora_rank64_lightx2v_4step_1217.safetensors", "size_bytes": 643825664, "source": { "provider": "huggingface", "repo": "lightx2v/Wan2.2-Distill-Loras", "path": "wan2.2_t2v_A14b_low_noise_lora_rank64_lightx2v_4step_1217.safetensors" }, "sha256": null }
],
"nodes": {
"KSamplerAdvanced": {
"steps": 20,
"cfg": 3.5,
"sampler_name": "euler",
"scheduler": "simple",
"steps_range": [10, 40],
"cfg_range": [1.0, 6.0],
"sampler_name_options": ["euler", "uni_pc", "dpmpp_2m"],
"scheduler_options": ["simple", "normal", "beta"]
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"id": "ideogram-default",
"name": "Default",
"category": "Photographic",
"text": "// Your description\n{cursor}\n\n// Photographic - Default\nA candid natural-light photograph, soft daylight, true-to-life color and texture, sharp focus with a gently blurred background, relaxed off-center framing.",
"text": "// Write your image in plain words. Optional scopes you can add:\n// $background{ a misty forest at dusk } = the scene / background\n// $alice{ red dress }, $bob{ blue suit } = one per subject (keeps them from blending; draw a box in the Region Box panel to place each)\n// Lines starting with // are just notes — they are ignored.\n{cursor}\n\nA candid natural-light photograph, soft daylight, true-to-life color, sharp focus.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
+19
View File
@@ -0,0 +1,19 @@
{
"name": "Wan 2.2 Video Prompts",
"prompts": [
{
"id": "wan-i2v",
"name": "Video (I2V)",
"category": "Video",
"text": "// Video Description\n{cursor}\n\nNegative Prompt: 色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"scope": { "type": "architecture", "architecture": "wan22" }
},
{
"id": "wan-t2v",
"name": "Video (T2V)",
"category": "Video",
"text": "// Video Description\n{cursor}\n\nNegative Prompt: 色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"scope": { "type": "architecture", "architecture": "wan22" }
}
]
}
+92
View File
@@ -0,0 +1,92 @@
{
"name": "Regional — Region Boxes",
"category": "Generation",
"order": 1,
"scope": {
"type": "family",
"architecture": "flux",
"family": "flux_dev"
},
"created_at": 1781740800,
"id": "flux-t2i-regionbox",
"nodes": [
{
"type": "KSampler",
"offset": [1560, 0],
"size": [270, 400],
"widgets_values": [3257151735, "randomize", 40, 1, "euler", "simple", 1],
"properties": { "Node name for S&R": "KSampler" }
},
{
"type": "VAEDecode",
"offset": [1900, 0],
"size": [210, 46],
"widgets_values": [],
"properties": { "Node name for S&R": "VAEDecode" }
},
{
"type": "SaveImage",
"offset": [1900, 120],
"size": [320, 380],
"widgets_values": ["flux1/%date:yyyy-MM-dd_hhmmss%"],
"properties": {}
},
{
"type": "DualCLIPLoader",
"offset": [820, 140],
"size": [300, 130],
"widgets_values": ["t5xxl_fp16.safetensors", "clip_l.safetensors", "flux", "default"],
"properties": { "Node name for S&R": "DualCLIPLoader" }
},
{
"type": "UNETLoader",
"offset": [820, 0],
"size": [300, 82],
"widgets_values": ["flux1-dev.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "EmptySD3LatentImage",
"offset": [820, 320],
"size": [270, 106],
"widgets_values": [832, 1216, 1],
"properties": { "Node name for S&R": "EmptySD3LatentImage" }
},
{
"type": "VAELoader",
"offset": [820, 470],
"size": [246, 58],
"widgets_values": ["ae.safetensors"],
"properties": { "Node name for S&R": "VAELoader" }
},
{
"type": "PromptChain_RegionalConditioning",
"offset": [1200, 0],
"size": [300, 150],
"widgets_values": [22],
"properties": { "Node name for S&R": "PromptChain_RegionalConditioning" }
},
{
"type": "PromptChain_RegionBox",
"offset": [1190, 200],
"size": [340, 400],
"widgets_values": ["", 832, 1216, 0],
"properties": { "Node name for S&R": "PromptChain_RegionBox" }
}
],
"connections": [
{ "from_node_idx": 4, "from_slot": 0, "to_node_idx": 0, "to_slot": 0 },
{ "from_node_idx": 3, "from_slot": 0, "to_node_idx": 7, "to_slot": 0 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 0, "to_slot": 1 },
{ "from_node_idx": 7, "from_slot": 1, "to_node_idx": 0, "to_slot": 2 },
{ "from_node_idx": 8, "from_slot": 0, "to_node_idx": 7, "to_slot": 2 },
{ "from_node_idx": 8, "from_slot": 1, "to_node_idx": 7, "to_slot": 3 },
{ "from_node_idx": 5, "from_slot": 0, "to_node_idx": 0, "to_slot": 3 },
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 1, "to_slot": 0 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 1, "to_slot": 1 },
{ "from_node_idx": 1, "from_slot": 0, "to_node_idx": 2, "to_slot": 0 }
],
"anchorConnections": [
{ "from_slot": 3, "to_node_idx": 7, "to_slot": 1 }
]
}
+148
View File
@@ -0,0 +1,148 @@
{
"name": "Regional — Region Boxes",
"category": "Generation",
"order": 1,
"scope": {
"type": "architecture",
"architecture": "ideogram"
},
"created_at": 1781740800,
"id": "ideogram-t2i-regionbox",
"prompt_id": "ideogram-default",
"nodes": [
{
"type": "CLIPTextEncode",
"offset": [900, 0],
"size": [225, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "ConditioningZeroOut",
"offset": [1180, 0],
"size": [240, 48],
"widgets_values": [],
"properties": { "Node name for S&R": "ConditioningZeroOut" }
},
{
"type": "CFGOverride",
"offset": [1180, 120],
"size": [280, 106],
"widgets_values": [3.0, 0.7, 1.0],
"properties": { "Node name for S&R": "CFGOverride" }
},
{
"type": "DualModelGuider",
"offset": [1500, 120],
"size": [266, 150],
"widgets_values": [7.0],
"properties": { "Node name for S&R": "DualModelGuider" }
},
{
"type": "PromptChain_IdeogramSampler",
"offset": [1840, 360],
"size": [310, 220],
"widgets_values": [4, 0.08],
"properties": { "Node name for S&R": "PromptChain_IdeogramSampler" }
},
{
"type": "SaveImage",
"offset": [2440, 0],
"size": [400, 460],
"widgets_values": ["Ideogram4/%date:yyyy-MM-dd%"],
"properties": {}
},
{
"type": "UNETLoader",
"offset": [820, 200],
"size": [320, 82],
"widgets_values": ["ideogram4_fp8_scaled.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "UNETLoader",
"offset": [820, 320],
"size": [320, 82],
"widgets_values": ["ideogram4_unconditional_fp8_scaled.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "CLIPLoader",
"offset": [560, 0],
"size": [320, 110],
"widgets_values": ["qwen3vl_8b_fp8_scaled.safetensors", "ideogram4", "default"],
"properties": { "Node name for S&R": "CLIPLoader" }
},
{
"type": "VAELoader",
"offset": [1500, 300],
"size": [246, 58],
"widgets_values": ["flux2-vae.safetensors"],
"properties": { "Node name for S&R": "VAELoader" }
},
{
"type": "RandomNoise",
"offset": [1500, 400],
"size": [260, 82],
"widgets_values": [0, "randomize"],
"properties": { "Node name for S&R": "RandomNoise" }
},
{
"type": "KSamplerSelect",
"offset": [1500, 520],
"size": [240, 58],
"widgets_values": ["euler"],
"properties": { "Node name for S&R": "KSamplerSelect" }
},
{
"type": "Ideogram4Scheduler",
"offset": [1500, 620],
"size": [300, 154],
"widgets_values": [20, 1024, 1024, 0.5, 1.75],
"properties": { "Node name for S&R": "Ideogram4Scheduler" }
},
{
"type": "EmptyFlux2LatentImage",
"offset": [1500, 800],
"size": [290, 106],
"widgets_values": [1024, 1024, 1],
"properties": { "Node name for S&R": "EmptyFlux2LatentImage" }
},
{
"type": "PromptChain_IdeogramCaption",
"offset": [600, 180],
"size": [250, 82],
"widgets_values": ["caption"],
"properties": { "Node name for S&R": "PromptChain_IdeogramCaption" }
},
{
"type": "PromptChain_RegionBox",
"offset": [560, 300],
"size": [340, 400],
"widgets_values": ["", 1024, 1024, 0],
"properties": { "Node name for S&R": "PromptChain_RegionBox" }
}
],
"connections": [
{ "from_node_idx": 8, "from_slot": 0, "to_node_idx": 0, "to_slot": 0 },
{ "from_node_idx": 14, "from_slot": 0, "to_node_idx": 0, "to_slot": 1 },
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 3, "to_slot": 1 },
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 1, "to_slot": 0 },
{ "from_node_idx": 1, "from_slot": 0, "to_node_idx": 3, "to_slot": 3 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 2, "to_slot": 0 },
{ "from_node_idx": 2, "from_slot": 0, "to_node_idx": 3, "to_slot": 0 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 3, "to_slot": 2 },
{ "from_node_idx": 3, "from_slot": 0, "to_node_idx": 4, "to_slot": 1 },
{ "from_node_idx": 10, "from_slot": 0, "to_node_idx": 4, "to_slot": 0 },
{ "from_node_idx": 11, "from_slot": 0, "to_node_idx": 4, "to_slot": 2 },
{ "from_node_idx": 12, "from_slot": 0, "to_node_idx": 4, "to_slot": 3 },
{ "from_node_idx": 13, "from_slot": 0, "to_node_idx": 4, "to_slot": 4 },
{ "from_node_idx": 9, "from_slot": 0, "to_node_idx": 4, "to_slot": 5 },
{ "from_node_idx": 4, "from_slot": 0, "to_node_idx": 5, "to_slot": 0 },
{ "from_node_idx": 15, "from_slot": 1, "to_node_idx": 14, "to_slot": 2 }
],
"anchorConnections": [
{ "from_slot": 1, "to_node_idx": 14, "to_slot": 0 },
{ "from_slot": 3, "to_node_idx": 14, "to_slot": 1 }
]
}
+3 -2
View File
@@ -112,7 +112,7 @@
"type": "PromptChain_IdeogramCaption",
"offset": [600, 180],
"size": [250, 82],
"widgets_values": ["envelope"],
"widgets_values": ["caption"],
"properties": { "Node name for S&R": "PromptChain_IdeogramCaption" }
}
],
@@ -134,6 +134,7 @@
{ "from_node_idx": 4, "from_slot": 0, "to_node_idx": 5, "to_slot": 0 }
],
"anchorConnections": [
{ "from_slot": 1, "to_node_idx": 14, "to_slot": 0 }
{ "from_slot": 1, "to_node_idx": 14, "to_slot": 0 },
{ "from_slot": 3, "to_node_idx": 14, "to_slot": 1 }
]
}
+86
View File
@@ -0,0 +1,86 @@
{
"name": "Regional — Region Boxes",
"category": "Generation",
"order": 1,
"scope": {
"type": "architecture",
"architecture": "sdxl"
},
"created_at": 1781740800,
"id": "sdxl-t2i-regionbox",
"nodes": [
{
"type": "KSampler",
"offset": [1560, 0],
"size": [300, 474],
"widgets_values": [674787291728667, "randomize", 30, 5, "euler", "normal", 1],
"properties": { "Node name for S&R": "KSampler" }
},
{
"type": "VAEDecode",
"offset": [1900, 0],
"size": [210, 46],
"widgets_values": [],
"properties": { "Node name for S&R": "VAEDecode" }
},
{
"type": "SaveImage",
"offset": [1900, 120],
"size": [320, 380],
"widgets_values": ["{model_name}/%date:yyyy-MM-dd_hhmmss%"],
"properties": {}
},
{
"type": "CLIPSetLastLayer",
"offset": [820, 220],
"size": [260, 60],
"widgets_values": [-2],
"properties": { "Node name for S&R": "CLIPSetLastLayer" }
},
{
"type": "CheckpointLoaderSimple",
"offset": [820, 0],
"size": [360, 100],
"widgets_values": ["PVCStyleModelMovable_epsIll11.safetensors"],
"properties": { "Node name for S&R": "CheckpointLoaderSimple" }
},
{
"type": "EmptyLatentImage",
"offset": [820, 320],
"size": [300, 106],
"widgets_values": [832, 1216, 1],
"properties": { "Node name for S&R": "EmptyLatentImage" }
},
{
"type": "PromptChain_AttentionCouple",
"offset": [1200, 0],
"size": [300, 170],
"widgets_values": [0.5, 22],
"properties": { "Node name for S&R": "PromptChain_AttentionCouple" }
},
{
"type": "PromptChain_RegionBox",
"offset": [1190, 220],
"size": [340, 400],
"widgets_values": ["", 832, 1216, 0],
"properties": { "Node name for S&R": "PromptChain_RegionBox" }
}
],
"connections": [
{ "from_node_idx": 4, "from_slot": 0, "to_node_idx": 6, "to_slot": 0 },
{ "from_node_idx": 4, "from_slot": 1, "to_node_idx": 3, "to_slot": 0 },
{ "from_node_idx": 3, "from_slot": 0, "to_node_idx": 6, "to_slot": 1 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 0, "to_slot": 0 },
{ "from_node_idx": 6, "from_slot": 1, "to_node_idx": 0, "to_slot": 1 },
{ "from_node_idx": 6, "from_slot": 2, "to_node_idx": 0, "to_slot": 2 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 6, "to_slot": 3 },
{ "from_node_idx": 7, "from_slot": 1, "to_node_idx": 6, "to_slot": 4 },
{ "from_node_idx": 5, "from_slot": 0, "to_node_idx": 0, "to_slot": 3 },
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 1, "to_slot": 0 },
{ "from_node_idx": 4, "from_slot": 2, "to_node_idx": 1, "to_slot": 1 },
{ "from_node_idx": 1, "from_slot": 0, "to_node_idx": 2, "to_slot": 0 }
],
"anchorConnections": [
{ "from_slot": 3, "to_node_idx": 6, "to_slot": 2 }
]
}
+324
View File
@@ -0,0 +1,324 @@
{
"name": "Image-to-Video (5B, GGUF)",
"category": "Video",
"order": 2,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_5b_gguf"
},
"created_at": 1782000000,
"id": "wan22-5b-i2v-gguf",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UnetLoaderGGUF",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-TI2V-5B-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
140
],
"size": [
315,
80
],
"widgets_values": [
8
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan2.2_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
}
},
{
"type": "Wan22ImageToVideoLatent",
"offset": [
780,
0
],
"size": [
300,
150
],
"widgets_values": [
1280,
704,
121,
1
],
"properties": {
"Node name for S&R": "Wan22ImageToVideoLatent"
}
},
{
"type": "KSampler",
"offset": [
1140,
0
],
"size": [
300,
262
],
"widgets_values": [
0,
"randomize",
20,
5,
"uni_pc",
"simple",
1
],
"properties": {
"Node name for S&R": "KSampler"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
24
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 1
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 2
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 3
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 3,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 4,
"to_slot": 1
}
]
}
+325 -109
View File
@@ -1,109 +1,325 @@
{
"name": "Image-to-Video",
"category": "Video",
"order": 1,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_5b"
},
"created_at": 1775178000,
"id": "wan22-5b-i2v",
"nodes": [
{
"type": "UNETLoader",
"offset": [60, 0],
"size": [355, 83],
"widgets_values": ["wan2.2_ti2v_5B_fp16.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "ModelSamplingSD3",
"offset": [60, 120],
"size": [315, 80],
"widgets_values": [5.0],
"properties": { "Node name for S&R": "ModelSamplingSD3" }
},
{
"type": "CLIPLoader",
"offset": [60, 250],
"size": [340, 117],
"widgets_values": ["umt5_xxl_fp8_e4m3fn_scaled.safetensors", "wan", "default"],
"properties": { "Node name for S&R": "CLIPLoader" }
},
{
"type": "CLIPTextEncode",
"offset": [60, 420],
"size": [340, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "CLIPTextEncode",
"offset": [60, 580],
"size": [340, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "LoadImage",
"offset": [500, 0],
"size": [340, 460],
"widgets_values": ["", "image"],
"properties": { "Node name for S&R": "LoadImage" }
},
{
"type": "Wan22ImageToVideoLatent",
"offset": [500, 500],
"size": [300, 200],
"widgets_values": [1280, 704, 49],
"properties": { "Node name for S&R": "Wan22ImageToVideoLatent" }
},
{
"type": "VAELoader",
"offset": [900, 0],
"size": [290, 83],
"widgets_values": ["wan2.2_vae.safetensors"],
"properties": { "Node name for S&R": "VAELoader" }
},
{
"type": "KSampler",
"offset": [900, 130],
"size": [300, 500],
"widgets_values": [0, "randomize", 30, 4.0, "euler", "simple", 1.0],
"properties": { "Node name for S&R": "KSampler" }
},
{
"type": "VAEDecode",
"offset": [1260, 0],
"size": [240, 72],
"widgets_values": [],
"properties": { "Node name for S&R": "VAEDecode" }
},
{
"type": "CreateVideo",
"offset": [1260, 120],
"size": [330, 400],
"widgets_values": [16, "image/gif", 0],
"properties": { "Node name for S&R": "CreateVideo" }
}
],
"connections": [
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 1, "to_slot": 0 },
{ "from_node_idx": 1, "from_slot": 0, "to_node_idx": 8, "to_slot": 0 },
{ "from_node_idx": 2, "from_slot": 0, "to_node_idx": 3, "to_slot": 0 },
{ "from_node_idx": 2, "from_slot": 0, "to_node_idx": 4, "to_slot": 0 },
{ "from_node_idx": 3, "from_slot": 0, "to_node_idx": 8, "to_slot": 1 },
{ "from_node_idx": 4, "from_slot": 0, "to_node_idx": 8, "to_slot": 2 },
{ "from_node_idx": 5, "from_slot": 0, "to_node_idx": 6, "to_slot": 0 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 8, "to_slot": 3 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 6, "to_slot": 1 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 9, "to_slot": 1 },
{ "from_node_idx": 8, "from_slot": 0, "to_node_idx": 9, "to_slot": 0 },
{ "from_node_idx": 9, "from_slot": 0, "to_node_idx": 10, "to_slot": 0 }
],
"anchorConnections": [
{ "from_slot": 1, "to_node_idx": 3, "to_slot": 1 },
{ "from_slot": 2, "to_node_idx": 4, "to_slot": 1 }
]
}
{
"name": "Image-to-Video (5B)",
"category": "Video",
"order": 0,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_5b"
},
"created_at": 1782000000,
"id": "wan22-5b-i2v",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UNETLoader",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_ti2v_5B_fp16.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
140
],
"size": [
315,
80
],
"widgets_values": [
8
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan2.2_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
}
},
{
"type": "Wan22ImageToVideoLatent",
"offset": [
780,
0
],
"size": [
300,
150
],
"widgets_values": [
1280,
704,
121,
1
],
"properties": {
"Node name for S&R": "Wan22ImageToVideoLatent"
}
},
{
"type": "KSampler",
"offset": [
1140,
0
],
"size": [
300,
262
],
"widgets_values": [
0,
"randomize",
20,
5,
"uni_pc",
"simple",
1
],
"properties": {
"Node name for S&R": "KSampler"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
24
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 1
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 2
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 3
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 3,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 4,
"to_slot": 1
}
]
}
+300
View File
@@ -0,0 +1,300 @@
{
"name": "Text-to-Video (5B, GGUF)",
"category": "Video",
"order": 3,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_5b_gguf"
},
"created_at": 1782000000,
"id": "wan22-5b-t2v-gguf",
"prompt_id": "wan-t2v",
"lockModels": true,
"nodes": [
{
"type": "UnetLoaderGGUF",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-TI2V-5B-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
140
],
"size": [
315,
80
],
"widgets_values": [
8
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan2.2_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "Wan22ImageToVideoLatent",
"offset": [
780,
0
],
"size": [
300,
150
],
"widgets_values": [
1280,
704,
121,
1
],
"properties": {
"Node name for S&R": "Wan22ImageToVideoLatent"
}
},
{
"type": "KSampler",
"offset": [
1140,
0
],
"size": [
300,
262
],
"widgets_values": [
0,
"randomize",
20,
5,
"uni_pc",
"simple",
1
],
"properties": {
"Node name for S&R": "KSampler"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
24
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 6,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 2
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 3
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 1
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 3,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 4,
"to_slot": 1
}
]
}
+301 -100
View File
@@ -1,100 +1,301 @@
{
"name": "Text-to-Video",
"category": "Video",
"order": 0,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_5b"
},
"created_at": 1775178000,
"id": "wan22-5b-t2v",
"nodes": [
{
"type": "UNETLoader",
"offset": [60, 0],
"size": [355, 83],
"widgets_values": ["wan2.2_ti2v_5B_fp16.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "ModelSamplingSD3",
"offset": [60, 120],
"size": [315, 80],
"widgets_values": [5.0],
"properties": { "Node name for S&R": "ModelSamplingSD3" }
},
{
"type": "CLIPLoader",
"offset": [60, 250],
"size": [340, 117],
"widgets_values": ["umt5_xxl_fp8_e4m3fn_scaled.safetensors", "wan", "default"],
"properties": { "Node name for S&R": "CLIPLoader" }
},
{
"type": "CLIPTextEncode",
"offset": [60, 420],
"size": [340, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "CLIPTextEncode",
"offset": [60, 580],
"size": [340, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "EmptyHunyuanLatentVideo",
"offset": [500, 0],
"size": [290, 170],
"widgets_values": [1280, 704, 49, 1],
"properties": { "Node name for S&R": "EmptyHunyuanLatentVideo" }
},
{
"type": "VAELoader",
"offset": [500, 210],
"size": [290, 83],
"widgets_values": ["wan2.2_vae.safetensors"],
"properties": { "Node name for S&R": "VAELoader" }
},
{
"type": "KSampler",
"offset": [860, 0],
"size": [300, 500],
"widgets_values": [0, "randomize", 30, 4.0, "euler", "simple", 1.0],
"properties": { "Node name for S&R": "KSampler" }
},
{
"type": "VAEDecode",
"offset": [1220, 0],
"size": [240, 72],
"widgets_values": [],
"properties": { "Node name for S&R": "VAEDecode" }
},
{
"type": "CreateVideo",
"offset": [1220, 120],
"size": [330, 400],
"widgets_values": [16, "image/gif", 0],
"properties": { "Node name for S&R": "CreateVideo" }
}
],
"connections": [
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 1, "to_slot": 0 },
{ "from_node_idx": 1, "from_slot": 0, "to_node_idx": 7, "to_slot": 0 },
{ "from_node_idx": 2, "from_slot": 0, "to_node_idx": 3, "to_slot": 0 },
{ "from_node_idx": 2, "from_slot": 0, "to_node_idx": 4, "to_slot": 0 },
{ "from_node_idx": 3, "from_slot": 0, "to_node_idx": 7, "to_slot": 1 },
{ "from_node_idx": 4, "from_slot": 0, "to_node_idx": 7, "to_slot": 2 },
{ "from_node_idx": 5, "from_slot": 0, "to_node_idx": 7, "to_slot": 3 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 8, "to_slot": 1 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 8, "to_slot": 0 },
{ "from_node_idx": 8, "from_slot": 0, "to_node_idx": 9, "to_slot": 0 }
],
"anchorConnections": [
{ "from_slot": 1, "to_node_idx": 3, "to_slot": 1 },
{ "from_slot": 2, "to_node_idx": 4, "to_slot": 1 }
]
}
{
"name": "Text-to-Video (5B)",
"category": "Video",
"order": 1,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_5b"
},
"created_at": 1782000000,
"id": "wan22-5b-t2v",
"prompt_id": "wan-t2v",
"lockModels": true,
"nodes": [
{
"type": "UNETLoader",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_ti2v_5B_fp16.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
140
],
"size": [
315,
80
],
"widgets_values": [
8
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan2.2_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "Wan22ImageToVideoLatent",
"offset": [
780,
0
],
"size": [
300,
150
],
"widgets_values": [
1280,
704,
121,
1
],
"properties": {
"Node name for S&R": "Wan22ImageToVideoLatent"
}
},
{
"type": "KSampler",
"offset": [
1140,
0
],
"size": [
300,
262
],
"widgets_values": [
0,
"randomize",
20,
5,
"uni_pc",
"simple",
1
],
"properties": {
"Node name for S&R": "KSampler"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
24
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 6,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 2
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 3
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 1
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 3,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 4,
"to_slot": 1
}
]
}
+505
View File
@@ -0,0 +1,505 @@
{
"name": "First-Last-Frame (FLF2V, Fast 4-step)",
"category": "Video",
"order": 4,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_i2v"
},
"created_at": 1782000000,
"id": "wan22-flf2v-fast",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UNETLoader",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
120
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors",
0.7
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UNETLoader",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
540
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors",
1
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
},
"title": "Start Frame"
},
{
"type": "WanFirstLastFrameToVideo",
"offset": [
780,
0
],
"size": [
300,
220
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "WanFirstLastFrameToVideo"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
4,
1,
"euler",
"simple",
0,
2,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
4,
1,
"euler",
"simple",
2,
4,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
},
{
"type": "LoadImage",
"offset": [
420,
1100
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
},
"title": "End Frame"
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 2,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 5
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 1,
"to_node_idx": 12,
"to_slot": 2
},
{
"from_node_idx": 11,
"from_slot": 2,
"to_node_idx": 12,
"to_slot": 3
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 1,
"to_node_idx": 13,
"to_slot": 2
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 3
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 1
},
{
"from_node_idx": 14,
"from_slot": 0,
"to_node_idx": 15,
"to_slot": 0
},
{
"from_node_idx": 15,
"from_slot": 0,
"to_node_idx": 16,
"to_slot": 0
},
{
"from_node_idx": 17,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 6
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 8,
"to_slot": 1
}
]
}
+503
View File
@@ -0,0 +1,503 @@
{
"name": "First-Last-Frame (FLF2V, GGUF, Fast 4-step)",
"category": "Video",
"order": 5,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_i2v_gguf"
},
"created_at": 1782000000,
"id": "wan22-flf2v-gguf-fast",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UnetLoaderGGUF",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-I2V-A14B-HighNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
120
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors",
0.7
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UnetLoaderGGUF",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-I2V-A14B-LowNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
540
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors",
1
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
},
"title": "Start Frame"
},
{
"type": "WanFirstLastFrameToVideo",
"offset": [
780,
0
],
"size": [
300,
220
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "WanFirstLastFrameToVideo"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
4,
1,
"euler",
"simple",
0,
2,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
4,
1,
"euler",
"simple",
2,
4,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
},
{
"type": "LoadImage",
"offset": [
420,
1100
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
},
"title": "End Frame"
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 2,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 5
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 1,
"to_node_idx": 12,
"to_slot": 2
},
{
"from_node_idx": 11,
"from_slot": 2,
"to_node_idx": 12,
"to_slot": 3
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 1,
"to_node_idx": 13,
"to_slot": 2
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 3
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 1
},
{
"from_node_idx": 14,
"from_slot": 0,
"to_node_idx": 15,
"to_slot": 0
},
{
"from_node_idx": 15,
"from_slot": 0,
"to_node_idx": 16,
"to_slot": 0
},
{
"from_node_idx": 17,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 6
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 8,
"to_slot": 1
}
]
}
+455
View File
@@ -0,0 +1,455 @@
{
"name": "First-Last-Frame (FLF2V, GGUF)",
"category": "Video",
"order": 3,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_i2v_gguf"
},
"created_at": 1782000000,
"id": "wan22-flf2v-gguf",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UnetLoaderGGUF",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-I2V-A14B-HighNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
8
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UnetLoaderGGUF",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-I2V-A14B-LowNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
8
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
},
"title": "Start Frame"
},
{
"type": "WanFirstLastFrameToVideo",
"offset": [
780,
0
],
"size": [
300,
220
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "WanFirstLastFrameToVideo"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
20,
4,
"euler",
"simple",
0,
10,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
20,
4,
"euler",
"simple",
10,
20,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
},
{
"type": "LoadImage",
"offset": [
420,
1100
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
},
"title": "End Frame"
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 6,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 1
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 2
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 5
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 1,
"to_node_idx": 10,
"to_slot": 2
},
{
"from_node_idx": 9,
"from_slot": 2,
"to_node_idx": 10,
"to_slot": 3
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 1,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 3
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
},
{
"from_node_idx": 15,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 6
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 5,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 6,
"to_slot": 1
}
]
}
+457
View File
@@ -0,0 +1,457 @@
{
"name": "First-Last-Frame (FLF2V)",
"category": "Video",
"order": 2,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_i2v"
},
"created_at": 1782000000,
"id": "wan22-flf2v",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UNETLoader",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
8
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UNETLoader",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
8
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
},
"title": "Start Frame"
},
{
"type": "WanFirstLastFrameToVideo",
"offset": [
780,
0
],
"size": [
300,
220
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "WanFirstLastFrameToVideo"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
20,
4,
"euler",
"simple",
0,
10,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
20,
4,
"euler",
"simple",
10,
20,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
},
{
"type": "LoadImage",
"offset": [
420,
1100
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
},
"title": "End Frame"
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 6,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 1
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 2
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 5
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 1,
"to_node_idx": 10,
"to_slot": 2
},
{
"from_node_idx": 9,
"from_slot": 2,
"to_node_idx": 10,
"to_slot": 3
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 1,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 3
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
},
{
"from_node_idx": 15,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 6
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 5,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 6,
"to_slot": 1
}
]
}
+479
View File
@@ -0,0 +1,479 @@
{
"name": "Image-to-Video (Fast 4-step)",
"category": "Video",
"order": 1,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_i2v"
},
"created_at": 1782000000,
"id": "wan22-i2v-fast",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UNETLoader",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
120
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors",
0.7
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UNETLoader",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
540
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors",
1
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
}
},
{
"type": "WanImageToVideo",
"offset": [
780,
0
],
"size": [
300,
220
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "WanImageToVideo"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
4,
1,
"euler",
"simple",
0,
2,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
4,
1,
"euler",
"simple",
2,
4,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 2,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 4
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 1,
"to_node_idx": 12,
"to_slot": 2
},
{
"from_node_idx": 11,
"from_slot": 2,
"to_node_idx": 12,
"to_slot": 3
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 1,
"to_node_idx": 13,
"to_slot": 2
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 3
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 1
},
{
"from_node_idx": 14,
"from_slot": 0,
"to_node_idx": 15,
"to_slot": 0
},
{
"from_node_idx": 15,
"from_slot": 0,
"to_node_idx": 16,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 8,
"to_slot": 1
}
]
}
+477
View File
@@ -0,0 +1,477 @@
{
"name": "Image-to-Video (GGUF, Fast 4-step)",
"category": "Video",
"order": 3,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_i2v_gguf"
},
"created_at": 1782000000,
"id": "wan22-i2v-gguf-fast",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UnetLoaderGGUF",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-I2V-A14B-HighNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
120
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors",
0.7
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UnetLoaderGGUF",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-I2V-A14B-LowNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
540
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors",
1
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
}
},
{
"type": "WanImageToVideo",
"offset": [
780,
0
],
"size": [
300,
220
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "WanImageToVideo"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
4,
1,
"euler",
"simple",
0,
2,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
4,
1,
"euler",
"simple",
2,
4,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 2,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 4
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 1,
"to_node_idx": 12,
"to_slot": 2
},
{
"from_node_idx": 11,
"from_slot": 2,
"to_node_idx": 12,
"to_slot": 3
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 1,
"to_node_idx": 13,
"to_slot": 2
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 3
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 1
},
{
"from_node_idx": 14,
"from_slot": 0,
"to_node_idx": 15,
"to_slot": 0
},
{
"from_node_idx": 15,
"from_slot": 0,
"to_node_idx": 16,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 8,
"to_slot": 1
}
]
}
+429
View File
@@ -0,0 +1,429 @@
{
"name": "Image-to-Video (GGUF)",
"category": "Video",
"order": 2,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_i2v_gguf"
},
"created_at": 1782000000,
"id": "wan22-i2v-gguf",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UnetLoaderGGUF",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-I2V-A14B-HighNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UnetLoaderGGUF",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-I2V-A14B-LowNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
}
},
{
"type": "WanImageToVideo",
"offset": [
780,
0
],
"size": [
300,
220
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "WanImageToVideo"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
20,
3.5,
"euler",
"simple",
0,
10,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
20,
3.5,
"euler",
"simple",
10,
20,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 6,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 1
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 2
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 4
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 1,
"to_node_idx": 10,
"to_slot": 2
},
{
"from_node_idx": 9,
"from_slot": 2,
"to_node_idx": 10,
"to_slot": 3
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 1,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 3
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 5,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 6,
"to_slot": 1
}
]
}
+431 -153
View File
@@ -1,153 +1,431 @@
{
"name": "Image-to-Video",
"category": "Video",
"order": 0,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_i2v"
},
"created_at": 1775178000,
"id": "wan22-i2v",
"nodes": [
{
"type": "UNETLoader",
"offset": [60, 0],
"size": [355, 83],
"widgets_values": ["wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "LoraLoaderModelOnly",
"offset": [60, 120],
"size": [355, 112],
"widgets_values": ["wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors", 1.0],
"properties": { "Node name for S&R": "LoraLoaderModelOnly" }
},
{
"type": "ModelSamplingSD3",
"offset": [60, 280],
"size": [315, 80],
"widgets_values": [5.0],
"properties": { "Node name for S&R": "ModelSamplingSD3" }
},
{
"type": "UNETLoader",
"offset": [60, 410],
"size": [355, 83],
"widgets_values": ["wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "LoraLoaderModelOnly",
"offset": [60, 530],
"size": [355, 112],
"widgets_values": ["wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors", 1.0],
"properties": { "Node name for S&R": "LoraLoaderModelOnly" }
},
{
"type": "ModelSamplingSD3",
"offset": [60, 690],
"size": [315, 80],
"widgets_values": [5.0],
"properties": { "Node name for S&R": "ModelSamplingSD3" }
},
{
"type": "CLIPLoader",
"offset": [500, 0],
"size": [340, 117],
"widgets_values": ["umt5_xxl_fp8_e4m3fn_scaled.safetensors", "wan", "default"],
"properties": { "Node name for S&R": "CLIPLoader" }
},
{
"type": "CLIPTextEncode",
"offset": [500, 160],
"size": [340, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "CLIPTextEncode",
"offset": [500, 320],
"size": [340, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "VAELoader",
"offset": [500, 480],
"size": [290, 83],
"widgets_values": ["wan_2.1_vae.safetensors"],
"properties": { "Node name for S&R": "VAELoader" }
},
{
"type": "LoadImage",
"offset": [500, 610],
"size": [340, 460],
"widgets_values": ["", "image"],
"properties": { "Node name for S&R": "LoadImage" }
},
{
"type": "WanImageToVideo",
"offset": [900, 0],
"size": [300, 220],
"widgets_values": [640, 640, 81, 1],
"properties": { "Node name for S&R": "WanImageToVideo" }
},
{
"type": "KSamplerAdvanced",
"offset": [1260, 0],
"size": [300, 504],
"widgets_values": ["enable", 0, "randomize", 4, 1.0, "euler", "simple", 0, 2, "enable"],
"properties": { "Node name for S&R": "KSamplerAdvanced" }
},
{
"type": "KSamplerAdvanced",
"offset": [1260, 550],
"size": [300, 504],
"widgets_values": ["disable", 0, "fixed", 4, 1.0, "euler", "simple", 2, 4, "disable"],
"properties": { "Node name for S&R": "KSamplerAdvanced" }
},
{
"type": "VAEDecode",
"offset": [1620, 0],
"size": [240, 72],
"widgets_values": [],
"properties": { "Node name for S&R": "VAEDecode" }
},
{
"type": "CreateVideo",
"offset": [1620, 120],
"size": [330, 400],
"widgets_values": [16],
"properties": { "Node name for S&R": "CreateVideo" }
}
],
"connections": [
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 1, "to_slot": 0 },
{ "from_node_idx": 1, "from_slot": 0, "to_node_idx": 2, "to_slot": 0 },
{ "from_node_idx": 3, "from_slot": 0, "to_node_idx": 4, "to_slot": 0 },
{ "from_node_idx": 4, "from_slot": 0, "to_node_idx": 5, "to_slot": 0 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 7, "to_slot": 0 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 8, "to_slot": 0 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 11, "to_slot": 0 },
{ "from_node_idx": 8, "from_slot": 0, "to_node_idx": 11, "to_slot": 1 },
{ "from_node_idx": 9, "from_slot": 0, "to_node_idx": 11, "to_slot": 2 },
{ "from_node_idx": 9, "from_slot": 0, "to_node_idx": 14, "to_slot": 1 },
{ "from_node_idx": 10, "from_slot": 0, "to_node_idx": 11, "to_slot": 4 },
{ "from_node_idx": 2, "from_slot": 0, "to_node_idx": 12, "to_slot": 0 },
{ "from_node_idx": 11, "from_slot": 0, "to_node_idx": 12, "to_slot": 1 },
{ "from_node_idx": 11, "from_slot": 1, "to_node_idx": 12, "to_slot": 2 },
{ "from_node_idx": 11, "from_slot": 2, "to_node_idx": 12, "to_slot": 3 },
{ "from_node_idx": 5, "from_slot": 0, "to_node_idx": 13, "to_slot": 0 },
{ "from_node_idx": 11, "from_slot": 0, "to_node_idx": 13, "to_slot": 1 },
{ "from_node_idx": 11, "from_slot": 1, "to_node_idx": 13, "to_slot": 2 },
{ "from_node_idx": 12, "from_slot": 0, "to_node_idx": 13, "to_slot": 3 },
{ "from_node_idx": 13, "from_slot": 0, "to_node_idx": 14, "to_slot": 0 },
{ "from_node_idx": 14, "from_slot": 0, "to_node_idx": 15, "to_slot": 0 }
],
"anchorConnections": [
{ "from_slot": 1, "to_node_idx": 7, "to_slot": 1 },
{ "from_slot": 2, "to_node_idx": 8, "to_slot": 1 }
]
}
{
"name": "Image-to-Video",
"category": "Video",
"order": 0,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_i2v"
},
"created_at": 1782000000,
"id": "wan22-i2v",
"prompt_id": "wan-i2v",
"lockModels": true,
"nodes": [
{
"type": "UNETLoader",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UNETLoader",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "VAELoader",
"offset": [
420,
480
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "LoadImage",
"offset": [
420,
610
],
"size": [
340,
460
],
"widgets_values": [
"",
"image"
],
"properties": {
"Node name for S&R": "LoadImage"
}
},
{
"type": "WanImageToVideo",
"offset": [
780,
0
],
"size": [
300,
220
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "WanImageToVideo"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
20,
3.5,
"euler",
"simple",
0,
10,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
20,
3.5,
"euler",
"simple",
10,
20,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 6,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 1
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 2
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 4
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 1,
"to_node_idx": 10,
"to_slot": 2
},
{
"from_node_idx": 9,
"from_slot": 2,
"to_node_idx": 10,
"to_slot": 3
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 9,
"from_slot": 1,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 3
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 5,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 6,
"to_slot": 1
}
]
}
+437
View File
@@ -0,0 +1,437 @@
{
"name": "Text-to-Video (Fast 4-step)",
"category": "Video",
"order": 1,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_t2v"
},
"created_at": 1782000000,
"id": "wan22-t2v-fast",
"prompt_id": "wan-t2v",
"lockModels": true,
"nodes": [
{
"type": "UNETLoader",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
120
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_t2v_A14b_high_noise_lora_rank64_lightx2v_4step_1217.safetensors",
0.7
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UNETLoader",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
540
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_t2v_A14b_low_noise_lora_rank64_lightx2v_4step_1217.safetensors",
1
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "EmptyHunyuanLatentVideo",
"offset": [
420,
480
],
"size": [
290,
144
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "EmptyHunyuanLatentVideo"
}
},
{
"type": "VAELoader",
"offset": [
420,
660
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
4,
1,
"euler",
"simple",
0,
2,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
4,
1,
"euler",
"simple",
2,
4,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 2,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 3
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 2
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 3
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 1
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
},
{
"from_node_idx": 14,
"from_slot": 0,
"to_node_idx": 15,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 8,
"to_slot": 1
}
]
}
+435
View File
@@ -0,0 +1,435 @@
{
"name": "Text-to-Video (GGUF, Fast 4-step)",
"category": "Video",
"order": 3,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_t2v_gguf"
},
"created_at": 1782000000,
"id": "wan22-t2v-gguf-fast",
"prompt_id": "wan-t2v",
"lockModels": true,
"nodes": [
{
"type": "UnetLoaderGGUF",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-T2V-A14B-HighNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
120
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_t2v_A14b_high_noise_lora_rank64_lightx2v_4step_1217.safetensors",
0.7
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UnetLoaderGGUF",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-T2V-A14B-LowNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "LoraLoaderModelOnly",
"offset": [
60,
540
],
"size": [
355,
112
],
"widgets_values": [
"wan2.2_t2v_A14b_low_noise_lora_rank64_lightx2v_4step_1217.safetensors",
1
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "EmptyHunyuanLatentVideo",
"offset": [
420,
480
],
"size": [
290,
144
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "EmptyHunyuanLatentVideo"
}
},
{
"type": "VAELoader",
"offset": [
420,
660
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
4,
1,
"euler",
"simple",
0,
2,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
4,
1,
"euler",
"simple",
2,
4,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 2,
"to_slot": 0
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 4,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 7,
"to_slot": 0
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 8,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 2
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 3
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 1
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 2
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 3
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 1
},
{
"from_node_idx": 13,
"from_slot": 0,
"to_node_idx": 14,
"to_slot": 0
},
{
"from_node_idx": 14,
"from_slot": 0,
"to_node_idx": 15,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 7,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 8,
"to_slot": 1
}
]
}
+387
View File
@@ -0,0 +1,387 @@
{
"name": "Text-to-Video (GGUF)",
"category": "Video",
"order": 2,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_t2v_gguf"
},
"created_at": 1782000000,
"id": "wan22-t2v-gguf",
"prompt_id": "wan-t2v",
"lockModels": true,
"nodes": [
{
"type": "UnetLoaderGGUF",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-T2V-A14B-HighNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UnetLoaderGGUF",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"Wan2.2-T2V-A14B-LowNoise-Q5_K_M.gguf"
],
"properties": {
"Node name for S&R": "UnetLoaderGGUF"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "EmptyHunyuanLatentVideo",
"offset": [
420,
480
],
"size": [
290,
144
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "EmptyHunyuanLatentVideo"
}
},
{
"type": "VAELoader",
"offset": [
420,
660
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
20,
3.5,
"euler",
"simple",
0,
10,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
20,
3.5,
"euler",
"simple",
10,
20,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 6,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 1
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 2
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 3
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 1
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 2
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 3
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 5,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 6,
"to_slot": 1
}
]
}
+389 -142
View File
@@ -1,142 +1,389 @@
{
"name": "Text-to-Video",
"category": "Video",
"order": 0,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_t2v"
},
"created_at": 1775178000,
"id": "wan22-t2v",
"nodes": [
{
"type": "UNETLoader",
"offset": [60, 0],
"size": [355, 83],
"widgets_values": ["wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "ModelSamplingSD3",
"offset": [60, 120],
"size": [315, 80],
"widgets_values": [5.0],
"properties": { "Node name for S&R": "ModelSamplingSD3" }
},
{
"type": "LoraLoaderModelOnly",
"offset": [60, 240],
"size": [355, 112],
"widgets_values": ["wan2.2_t2v_lightx2v_4steps_lora_v1.1_high_noise.safetensors", 1.0],
"properties": { "Node name for S&R": "LoraLoaderModelOnly" }
},
{
"type": "UNETLoader",
"offset": [60, 400],
"size": [355, 83],
"widgets_values": ["wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "ModelSamplingSD3",
"offset": [60, 520],
"size": [315, 80],
"widgets_values": [5.0],
"properties": { "Node name for S&R": "ModelSamplingSD3" }
},
{
"type": "LoraLoaderModelOnly",
"offset": [60, 640],
"size": [355, 112],
"widgets_values": ["wan2.2_t2v_lightx2v_4steps_lora_v1.1_low_noise.safetensors", 1.0],
"properties": { "Node name for S&R": "LoraLoaderModelOnly" }
},
{
"type": "CLIPLoader",
"offset": [500, 0],
"size": [340, 117],
"widgets_values": ["umt5_xxl_fp8_e4m3fn_scaled.safetensors", "wan", "default"],
"properties": { "Node name for S&R": "CLIPLoader" }
},
{
"type": "CLIPTextEncode",
"offset": [500, 160],
"size": [340, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "CLIPTextEncode",
"offset": [500, 320],
"size": [340, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "EmptyHunyuanLatentVideo",
"offset": [500, 480],
"size": [290, 144],
"widgets_values": [640, 640, 81, 1],
"properties": { "Node name for S&R": "EmptyHunyuanLatentVideo" }
},
{
"type": "VAELoader",
"offset": [900, 0],
"size": [290, 83],
"widgets_values": ["wan_2.1_vae.safetensors"],
"properties": { "Node name for S&R": "VAELoader" }
},
{
"type": "KSamplerAdvanced",
"offset": [1260, 0],
"size": [300, 504],
"widgets_values": ["enable", 0, "randomize", 4, 1.0, "euler", "simple", 0, 2, "enable"],
"properties": { "Node name for S&R": "KSamplerAdvanced" }
},
{
"type": "KSamplerAdvanced",
"offset": [1260, 550],
"size": [300, 504],
"widgets_values": ["disable", 0, "randomize", 4, 1.0, "euler", "simple", 2, 4, "disable"],
"properties": { "Node name for S&R": "KSamplerAdvanced" }
},
{
"type": "VAEDecode",
"offset": [1620, 0],
"size": [240, 72],
"widgets_values": [],
"properties": { "Node name for S&R": "VAEDecode" }
},
{
"type": "CreateVideo",
"offset": [1620, 120],
"size": [330, 400],
"widgets_values": [16, "image/gif", 0],
"properties": { "Node name for S&R": "CreateVideo" }
}
],
"connections": [
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 1, "to_slot": 0 },
{ "from_node_idx": 1, "from_slot": 0, "to_node_idx": 2, "to_slot": 0 },
{ "from_node_idx": 2, "from_slot": 0, "to_node_idx": 11, "to_slot": 0 },
{ "from_node_idx": 3, "from_slot": 0, "to_node_idx": 4, "to_slot": 0 },
{ "from_node_idx": 4, "from_slot": 0, "to_node_idx": 5, "to_slot": 0 },
{ "from_node_idx": 5, "from_slot": 0, "to_node_idx": 12, "to_slot": 0 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 7, "to_slot": 0 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 8, "to_slot": 0 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 11, "to_slot": 1 },
{ "from_node_idx": 8, "from_slot": 0, "to_node_idx": 11, "to_slot": 2 },
{ "from_node_idx": 8, "from_slot": 0, "to_node_idx": 12, "to_slot": 2 },
{ "from_node_idx": 9, "from_slot": 0, "to_node_idx": 11, "to_slot": 3 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 12, "to_slot": 1 },
{ "from_node_idx": 11, "from_slot": 0, "to_node_idx": 12, "to_slot": 3 },
{ "from_node_idx": 10, "from_slot": 0, "to_node_idx": 13, "to_slot": 1 },
{ "from_node_idx": 12, "from_slot": 0, "to_node_idx": 13, "to_slot": 0 },
{ "from_node_idx": 13, "from_slot": 0, "to_node_idx": 14, "to_slot": 0 }
],
"anchorConnections": [
{ "from_slot": 1, "to_node_idx": 7, "to_slot": 1 },
{ "from_slot": 2, "to_node_idx": 8, "to_slot": 1 }
]
}
{
"name": "Text-to-Video",
"category": "Video",
"order": 0,
"scope": {
"type": "family",
"architecture": "wan22",
"family": "wan22_t2v"
},
"created_at": 1782000000,
"id": "wan22-t2v",
"prompt_id": "wan-t2v",
"lockModels": true,
"nodes": [
{
"type": "UNETLoader",
"offset": [
60,
0
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
280
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "UNETLoader",
"offset": [
60,
420
],
"size": [
355,
83
],
"widgets_values": [
"wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
"default"
],
"properties": {
"Node name for S&R": "UNETLoader"
}
},
{
"type": "ModelSamplingSD3",
"offset": [
60,
700
],
"size": [
315,
80
],
"widgets_values": [
5
],
"properties": {
"Node name for S&R": "ModelSamplingSD3"
}
},
{
"type": "CLIPLoader",
"offset": [
420,
0
],
"size": [
340,
117
],
"widgets_values": [
"umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"wan",
"default"
],
"properties": {
"Node name for S&R": "CLIPLoader"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
160
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "CLIPTextEncode",
"offset": [
420,
320
],
"size": [
340,
116
],
"widgets_values": [
""
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
}
},
{
"type": "EmptyHunyuanLatentVideo",
"offset": [
420,
480
],
"size": [
290,
144
],
"widgets_values": [
640,
640,
81,
1
],
"properties": {
"Node name for S&R": "EmptyHunyuanLatentVideo"
}
},
{
"type": "VAELoader",
"offset": [
420,
660
],
"size": [
290,
83
],
"widgets_values": [
"wan_2.1_vae.safetensors"
],
"properties": {
"Node name for S&R": "VAELoader"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
0
],
"size": [
300,
504
],
"widgets_values": [
"enable",
0,
"randomize",
20,
3.5,
"euler",
"simple",
0,
10,
"enable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "KSamplerAdvanced",
"offset": [
1140,
560
],
"size": [
300,
504
],
"widgets_values": [
"disable",
0,
"fixed",
20,
3.5,
"euler",
"simple",
10,
20,
"disable"
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
}
},
{
"type": "VAEDecode",
"offset": [
1500,
0
],
"size": [
240,
72
],
"widgets_values": [],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"type": "CreateVideo",
"offset": [
1500,
120
],
"size": [
330,
120
],
"widgets_values": [
16
],
"properties": {
"Node name for S&R": "CreateVideo"
}
},
{
"type": "SaveVideo",
"offset": [
1860,
120
],
"size": [
330,
200
],
"widgets_values": [
"video/PromptChain",
"auto",
"auto"
],
"properties": {
"Node name for S&R": "SaveVideo"
}
}
],
"connections": [
{
"from_node_idx": 0,
"from_slot": 0,
"to_node_idx": 1,
"to_slot": 0
},
{
"from_node_idx": 2,
"from_slot": 0,
"to_node_idx": 3,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 5,
"to_slot": 0
},
{
"from_node_idx": 4,
"from_slot": 0,
"to_node_idx": 6,
"to_slot": 0
},
{
"from_node_idx": 1,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 1
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 2
},
{
"from_node_idx": 7,
"from_slot": 0,
"to_node_idx": 9,
"to_slot": 3
},
{
"from_node_idx": 3,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 0
},
{
"from_node_idx": 5,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 1
},
{
"from_node_idx": 6,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 2
},
{
"from_node_idx": 9,
"from_slot": 0,
"to_node_idx": 10,
"to_slot": 3
},
{
"from_node_idx": 10,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 0
},
{
"from_node_idx": 8,
"from_slot": 0,
"to_node_idx": 11,
"to_slot": 1
},
{
"from_node_idx": 11,
"from_slot": 0,
"to_node_idx": 12,
"to_slot": 0
},
{
"from_node_idx": 12,
"from_slot": 0,
"to_node_idx": 13,
"to_slot": 0
}
],
"anchorConnections": [
{
"from_slot": 1,
"to_node_idx": 5,
"to_slot": 1
},
{
"from_slot": 2,
"to_node_idx": 6,
"to_slot": 1
}
]
}
+27 -1
View File
@@ -73,9 +73,35 @@ async function handleDrop(e) {
const canvasX = (e.clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0];
const canvasY = (e.clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1];
const scope = assetData.scope || "output";
// Smart switch: dropping an output image onto a STRICTLY EMPTY graph restores
// its embedded ComfyUI workflow (the litegraph "workflow" PNG chunk) instead of
// adding a Load Image node — mirroring ComfyUI's native PNG file-drop. Gated to
// zero nodes because loadGraphData clean-wipes the canvas (same as the native
// drop), so on a populated graph that would silently destroy the user's work.
// The endpoint already exists (inpaint/upscale use it) and NaN-defuses the JSON.
if (!app.graph?._nodes?.length && /\.(png|webp)$/i.test(assetData.path)) {
try {
const wfResp = await api.fetchApi(
`/promptchain/image-workflow?scope=${scope}&path=${encodeURIComponent(assetData.path)}`
);
if (wfResp.ok) {
const { workflow } = await wfResp.json();
if (workflow) {
await app.loadGraphData(workflow, true, true, assetData.name || assetData.path.split("/").pop());
app.extensionManager?.toast?.add?.({ severity: "success", summary: "Restored workflow from image", life: 2500 });
return;
}
}
} catch (err) {
// any failure (no workflow chunk, fetch error) falls through to Load Image
log("workflow restore failed; falling back to Load Image", err);
}
}
try {
// fetch image via browse preview
const scope = assetData.scope || "output";
const fetchResponse = await api.fetchApi(
`/promptchain/browse/preview?scope=${scope}&path=${encodeURIComponent(assetData.path)}`
);
+152
View File
@@ -0,0 +1,152 @@
// "Extend video" background runner. Builds the I2V continuation graph off-screen
// (source video's last frame -> next clip), queues it, and records the result as
// a lineage CHILD of the source video (parent_filename) so it joins the family.
// The user's open workflow is NEVER touched. Mirrors repose-background.js.
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { recordGeneration, externallyTrackedPrompts, armExternalQueue, disarmExternalQueue } from "./history.js";
import { buildExtendGraph } from "./extend-from-image.js";
function toast(severity, detail, life = 8000) {
app.extensionManager?.toast?.add({ severity, summary: "Extend", detail, life });
}
function createTracker() {
const listeners = new Set();
const tracker = {
state: { phase: "building" },
subscribe(fn) { listeners.add(fn); fn(tracker.state); return () => listeners.delete(fn); },
emit(state) { tracker.state = state; for (const fn of listeners) { try { fn(state); } catch {} } },
};
return tracker;
}
function viewUrl(img) {
if (!img?.filename) return "";
const q = new URLSearchParams({ filename: img.filename, subfolder: img.subfolder || "", type: img.type || "output" });
return api.apiURL ? api.apiURL(`/view?${q}`) : `/view?${q}`;
}
// opts: { templateId, lastFrameFilename, promptDoc, length, parentFilename }
export function runExtendInBackground(opts) {
const tracker = createTracker();
const run = { cancelled: false, promptId: null, executionStarted: false, cleanup: null };
tracker.cancel = async () => {
if (run.cancelled) return;
run.cancelled = true;
tracker.emit({ phase: "cancelled" });
toast("info", "Extend cancelled.");
try {
if (run.promptId) {
if (run.executionStarted) await api.interrupt();
else {
await api.fetchApi("/queue", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete: [run.promptId] }),
});
run.cleanup?.();
}
}
} catch (e) { console.warn("[Extend] cancel request failed", e); }
};
(async () => {
tracker.emit({ phase: "building" });
// Pass the WHOLE opts through — an explicit allowlist here silently dropped
// every Advanced control (recipe sampler knobs, dims, outFps, seed) the modal
// sends. buildExtendGraph reads what it needs and ignores the rest.
const built = await buildExtendGraph({ ...opts });
if (!built) { tracker.emit({ phase: "error", message: "couldn't build the extend graph" }); return; }
const { graph, workflowId } = built;
if (run.cancelled) return;
tracker.emit({ phase: "queueing" });
const serialized = await app.graphToPrompt(graph);
armExternalQueue();
let promptId = null;
try {
const previewMethod = app.extensionManager?.setting?.get?.("Comfy.Execution.PreviewMethod")
?? app.ui?.settings?.getSettingValue?.("Comfy.Execution.PreviewMethod") ?? "default";
const res = await api.queuePrompt(0, { output: serialized.output, workflow: serialized.workflow }, { previewMethod });
promptId = res?.prompt_id || null;
} catch (e) {
disarmExternalQueue();
const message = e?.response?.error?.message || e?.message || "queue rejected the prompt";
tracker.emit({ phase: "error", message });
toast("error", `Extend failed to queue: ${message}`);
return;
}
if (promptId) externallyTrackedPrompts.add(promptId);
disarmExternalQueue();
run.promptId = promptId;
if (!promptId) { tracker.emit({ phase: "error", message: "queue returned no prompt id" }); return; }
if (run.cancelled) {
try {
await api.fetchApi("/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: [promptId] }) });
} catch {}
externallyTrackedPrompts.delete(promptId);
return;
}
let lastImage = null, lastPreview = null;
const onStart = ({ detail }) => { if (detail?.prompt_id === promptId) run.executionStarted = true; };
const onProgress = ({ detail }) => {
if (detail?.prompt_id !== promptId || run.cancelled) return;
run.executionStarted = true;
tracker.emit({ ...tracker.state, phase: "running", value: detail?.value || 0, max: detail?.max || 0 });
};
const onPreview = ({ detail }) => {
if (run.cancelled || !run.executionStarted || !(detail instanceof Blob)) return;
if (lastPreview) URL.revokeObjectURL(lastPreview);
lastPreview = URL.createObjectURL(detail);
tracker.emit({ ...tracker.state, phase: "running", previewUrl: lastPreview });
};
const onExecuted = ({ detail }) => {
// SaveVideo emits its output under `images` (PreviewVideo {images,animated}),
// same shape as an image save — so the final clip is captured here.
if (detail?.prompt_id !== promptId || !detail?.output?.images?.length) return;
lastImage = detail.output.images[detail.output.images.length - 1];
};
const cleanup = () => {
api.removeEventListener("execution_start", onStart);
api.removeEventListener("progress", onProgress);
api.removeEventListener("b_preview", onPreview);
api.removeEventListener("executed", onExecuted);
api.removeEventListener("execution_success", onSuccess);
api.removeEventListener("execution_error", onError);
api.removeEventListener("execution_interrupted", onError);
if (promptId) externallyTrackedPrompts.delete(promptId);
};
run.cleanup = cleanup;
const onSuccess = async ({ detail }) => {
if (detail?.prompt_id !== promptId) return;
cleanup();
if (!lastImage) { tracker.emit({ phase: "error", message: "extend produced no clip" }); return; }
const entry = await recordGeneration(workflowId, lastImage.filename, lastImage.subfolder, "output", {
parent_filename: opts.parentFilename || "",
});
tracker.emit({ phase: "done", resultUrl: viewUrl(lastImage), resultHash: entry?.hash || null, entry });
};
const onError = ({ detail }) => {
if (detail?.prompt_id !== promptId) return;
cleanup();
tracker.emit({ phase: "error", message: detail?.exception_message || "extend render failed" });
};
api.addEventListener("execution_start", onStart);
api.addEventListener("progress", onProgress);
api.addEventListener("b_preview", onPreview);
api.addEventListener("executed", onExecuted);
api.addEventListener("execution_success", onSuccess);
api.addEventListener("execution_error", onError);
api.addEventListener("execution_interrupted", onError);
tracker.emit({ phase: "running" });
})().catch((e) => {
console.error("[Extend] background run failed", e);
tracker.emit({ phase: "error", message: e?.message || "unexpected failure" });
toast("error", `Extend failed: ${e?.message || e}`);
});
return tracker;
}
+247
View File
@@ -0,0 +1,247 @@
// "Extend video" graph builder. Instantiates an I2V template (VERBATIM — its
// lockModels recipe already matches the source clip's models) into an off-screen
// LGraph and overrides ONLY the start image (the source video's last frame) and
// the prompt, so the next clip continues from where the source ended. Mirrors
// buildReposeGraph (repose-from-image.js). The last frame is staged server-side
// by core/extend_api.py (/promptchain/extend/prepare).
import { api } from "../../../scripts/api.js";
import { resolveInstalledVariant } from "./model-bridge.js";
import { offscreenIdBase } from "./offscreen-graph.js";
const EMPTY_WORKFLOW = { last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 };
function freshWorkflowId() {
return globalThis.crypto?.randomUUID ? crypto.randomUUID() : `${Date.now().toString(16)}-extend`;
}
function setNamedWidget(node, name, value) {
const w = node?.widgets?.find((x) => x.name === name);
if (!w) return false;
if (Array.isArray(w.options?.values) && value != null && !w.options.values.includes(value)) {
w.options.values.push(value); // make a not-yet-listed file selectable
}
w.value = value;
w.callback?.(value);
return true;
}
// opts: { templateId, lastFrameFilename, promptDoc, length }
// Returns { graph, workflowId } or null.
export async function buildExtendGraph(opts) {
const LG = window.LiteGraph;
if (!LG) return null;
let template;
try {
const res = await api.fetchApi(`/promptchain/templates/${encodeURIComponent(opts.templateId)}`);
if (!res.ok) return null;
const data = await res.json();
template = data?.template || data;
} catch {
return null;
}
if (!template?.nodes?.length) return null;
const workflowId = freshWorkflowId();
const graph = new LG.LGraph(structuredClone({ ...EMPTY_WORKFLOW, id: workflowId }));
graph.last_node_id = offscreenIdBase(); // render-node ids must stay off the live canvas — see offscreen-graph.js
const created = new Array(template.nodes.length).fill(null);
// Keeps the dual experts on one downloaded quant when remapping baked variants.
const variantRef = { token: "" };
for (let i = 0; i < template.nodes.length; i++) {
const tpl = template.nodes[i];
const node = LG.createNode(tpl.type);
if (!node) { console.warn(`[Extend] unknown node type ${tpl.type}`); return null; }
graph.add(node);
if (tpl.title) node.title = tpl.title;
// Copy widget values verbatim — lockModels keeps the proven recipe (model /
// LoRA / VAE / CLIP files, steps, cfg, shift). Only the pieces below vary.
if (tpl.widgets_values?.length && node.widgets?.length) {
for (let w = 0; w < Math.min(tpl.widgets_values.length, node.widgets.length); w++) {
const widget = node.widgets[w];
if (widget.type === "IMAGEUPLOAD" || widget.type === "button") continue;
let val = tpl.widgets_values[w];
// The template bakes a fixed GGUF quant / safetensors precision; remap it
// to whichever variant of the same model file the user actually installed
// (same fix applyTemplate does — buildExtendGraph doesn't route through it).
if (typeof val === "string" && /\.(gguf|safetensors|ckpt)$/i.test(val)) {
val = resolveInstalledVariant(val, widget.options?.values, variantRef);
}
if (Array.isArray(widget.options?.values) && val != null && !widget.options.values.includes(val)) {
widget.options.values.push(val);
}
widget.value = val;
widget.callback?.(val);
}
}
created[i] = node;
}
// ── overrides: start image = source's last frame; optional clip dims/length/
// fps. Every override is OPTIONAL — an omitted opt keeps the template's tuned
// value, so default Extend behaviour is unchanged. ──
for (const node of created) {
if (!node) continue;
const type = node.comfyClass || node.type;
if (type === "LoadImage") {
setNamedWidget(node, "image", opts.lastFrameFilename);
} else if (type === "WanImageToVideo" || type === "WanFirstLastFrameToVideo") {
if (opts.length) setNamedWidget(node, "length", opts.length);
if (opts.width) setNamedWidget(node, "width", opts.width);
if (opts.height) setNamedWidget(node, "height", opts.height);
} else if (type === "CreateVideo" && opts.outFps != null) {
setNamedWidget(node, "fps", opts.outFps);
}
}
// ── advanced sampler overrides (all optional). The 14B recipe is DUAL-EXPERT:
// two KSamplerAdvanced (the high-noise expert runs the first slice of the step
// schedule, the low-noise expert the rest) + two ModelSamplingSD3. Identify the
// high expert by its add_noise="enable" and preserve the template's proportional
// step split so the MoE handoff stays intact; single-expert recipes (one
// sampler) just take the full [0, steps] window. ──
const widgetVal = (node, name) => node?.widgets?.find((w) => w.name === name)?.value;
const ksamplers = created.filter((n) => n && (n.comfyClass || n.type) === "KSamplerAdvanced");
const shiftNodes = created.filter((n) => n && (n.comfyClass || n.type) === "ModelSamplingSD3");
const highK = ksamplers.find((n) => widgetVal(n, "add_noise") === "enable") || ksamplers[0] || null;
const lowK = ksamplers.find((n) => n !== highK) || null;
if (opts.cfg != null) for (const k of ksamplers) setNamedWidget(k, "cfg", opts.cfg);
if (opts.sampler) for (const k of ksamplers) setNamedWidget(k, "sampler_name", opts.sampler);
if (opts.scheduler) for (const k of ksamplers) setNamedWidget(k, "scheduler", opts.scheduler);
if (opts.shift != null) for (const m of shiftNodes) setNamedWidget(m, "shift", opts.shift);
// Seed: a pinned seed fixes both experts; unpinned must inject a FRESH random
// seed each run, because the off-screen api.queuePrompt path never fires the
// "randomize" widget control — the baked noise_seed (0) would otherwise repeat
// byte-identical every Extend / Run Again. The high expert drives the noise
// (low has add_noise disabled), so randomizing it is enough.
if (opts.seed != null) {
for (const k of ksamplers) {
setNamedWidget(k, "noise_seed", opts.seed);
setNamedWidget(k, "control_after_generate", "fixed");
}
} else if (highK) {
setNamedWidget(highK, "noise_seed", Math.floor(Math.random() * 2 ** 31));
}
if (opts.steps != null && highK) {
const origSteps = Number(widgetVal(highK, "steps")) || opts.steps;
const origEnd = Number(widgetVal(highK, "end_at_step"));
setNamedWidget(highK, "steps", opts.steps);
setNamedWidget(highK, "start_at_step", 0);
if (lowK) {
const ratio = origSteps > 0 && origEnd > 0 ? origEnd / origSteps : 0.5;
const boundary = Math.max(1, Math.min(opts.steps - 1, Math.round(opts.steps * ratio)));
setNamedWidget(highK, "end_at_step", boundary);
setNamedWidget(lowK, "steps", opts.steps);
setNamedWidget(lowK, "start_at_step", boundary);
setNamedWidget(lowK, "end_at_step", opts.steps);
} else {
setNamedWidget(highK, "end_at_step", opts.steps);
}
}
// ── wiring ──
for (const conn of template.connections || []) {
const from = created[conn.from_node_idx];
const to = created[conn.to_node_idx];
if (from && to) {
try { from.connect(conn.from_slot, to, conn.to_slot); } catch (e) { console.warn("[Extend] wire failed", e); }
}
}
// ── prompt: PromptChain_PromptChain wired via anchorConnections so the raw doc
// compiles SERVER-SIDE (same engine repose/upscale use). Mirrors buildReposeGraph. ──
const anchors = template.anchorConnections || [];
if (anchors.length) {
const pc = LG.createNode("PromptChain_PromptChain");
if (!pc) { console.warn("[Extend] PromptChain_PromptChain unavailable (install the pack)"); return null; }
graph.add(pc);
const promptW = pc.widgets?.find((w) => w.name === "prompt");
if (promptW) { promptW.value = opts.promptDoc || ""; promptW.callback?.(promptW.value); }
pc.properties = pc.properties || {};
Object.assign(pc.properties, {
pcrMode: "switch", pcrSwitchIndex: 1, pcrLocked: false, pcrDisabled: false,
pcrCachedOutput: "", pcrCachedNegOutput: "", pcrCachedRegions: "",
});
const maxSlot = Math.max(...anchors.map((c) => c.from_slot));
if (!pc.outputs || pc.outputs.length <= maxSlot) {
const ref = LG.createNode(pc.comfyClass || pc.type);
if (ref?.outputs) {
for (let i = pc.outputs?.length || 0; i < ref.outputs.length; i++) {
pc.addOutput(ref.outputs[i].name, ref.outputs[i].type);
}
}
}
for (const conn of anchors) {
const to = created[conn.to_node_idx];
if (to) { try { pc.connect(conn.from_slot, to, conn.to_slot); } catch (e) { console.warn("[Extend] anchor wire failed", e); } }
}
}
// ── optional stitch: prepend the SOURCE clip so the result is ONE continuous
// video. Load the source -> frames; drop the continuation's duplicate first
// frame (ImageFromBatch from index 1 — it equals the source's last frame);
// concatenate (source + trimmed continuation) and re-encode through the
// template's own CreateVideo at the source fps. All core video/image nodes; if
// any is missing we silently leave the continuation-only graph intact. ──
// The continuation is native 16fps. A source can only be cleanly joined with it
// when the source is also 16 or a whole-number RIFE multiple of 16 (32/48/64) —
// those we either join directly or match by interpolating the continuation up.
// A non-multiple source (24fps 5B, foreign 25/30/60) can't be reconciled without
// frame-rate conversion, so we SKIP the stitch (save the continuation on its own)
// rather than silently slow the source half by re-encoding it at 16.
const NATIVE_FPS = 16;
const srcFps = Math.round(opts.fps || NATIVE_FPS);
const fpsMult = Math.round(srcFps / NATIVE_FPS);
const cleanForStitch = srcFps === NATIVE_FPS || (fpsMult >= 2 && srcFps === fpsMult * NATIVE_FPS);
if (opts.stitch && opts.sourceVideoFilename && !cleanForStitch) {
console.warn(`[Extend] source ${srcFps}fps can't be cleanly joined with a 16fps continuation; saving continuation only`);
}
if (opts.stitch && opts.sourceVideoFilename && cleanForStitch) {
const reg = LG.registered_node_types || {};
const need = ["LoadVideo", "GetVideoComponents", "ImageBatch", "ImageFromBatch"];
const vae = created.find((n) => n && (n.comfyClass || n.type) === "VAEDecode");
const cv = created.find((n) => n && (n.comfyClass || n.type) === "CreateVideo");
if (need.every((t) => reg[t]) && vae && cv) {
const load = LG.createNode("LoadVideo"); graph.add(load); setNamedWidget(load, "file", opts.sourceVideoFilename);
const comp = LG.createNode("GetVideoComponents"); graph.add(comp);
const drop = LG.createNode("ImageFromBatch"); graph.add(drop);
setNamedWidget(drop, "batch_index", 1);
setNamedWidget(drop, "length", 4096); // clamps to "rest of the batch" (default 1 would keep one frame)
const batch = LG.createNode("ImageBatch"); graph.add(batch);
try {
// Source is 16 or a clean RIFE multiple (32/48/64 — guaranteed by
// cleanForStitch). When it's a multiple, bring the native-16 continuation
// up to the same density first so both halves play at one rate; this is
// what makes "extend an already-smoothed clip" just work. RIFE is present
// whenever the source was smoothed.
const matchInterp = fpsMult >= 2 && reg["RIFE VFI"];
let contFrames = vae; // node feeding the continuation frames into the join
if (matchInterp) {
const rife = LG.createNode("RIFE VFI"); graph.add(rife);
setNamedWidget(rife, "ckpt_name", "rife49.pth");
setNamedWidget(rife, "multiplier", fpsMult);
vae.connect(0, rife, 0); // continuation 16fps -> interpolate up to source density
contFrames = rife;
}
load.connect(0, comp, 0); // source VIDEO -> GetVideoComponents.video
contFrames.connect(0, drop, 0); // (matched) continuation frames -> drop frame 0
comp.connect(0, batch, 0); // source frames -> ImageBatch.image1
drop.connect(0, batch, 1); // trimmed continuation -> ImageBatch.image2
batch.connect(0, cv, 0); // concatenated -> CreateVideo.images (replaces the VAEDecode link)
// Encode at the source's rate when matched (both halves now that density),
// else native 16 (the source was already 16). Never relabel un-matched
// native frames to a higher fps — that's the fast-play bug.
setNamedWidget(cv, "fps", matchInterp ? srcFps : NATIVE_FPS);
} catch (e) {
console.warn("[Extend] stitch wiring failed; producing continuation-only clip", e);
}
} else {
console.warn("[Extend] stitch requested but core video nodes unavailable; continuation-only");
}
}
return { graph, workflowId };
}
+79 -2
View File
@@ -89,6 +89,39 @@ function ensureComboOption(widget, val) {
}
}
// A model filename is "<base>-<variant>.<ext>", where <variant> is a GGUF quant
// (Q5_K_M, Q8_0, IQ2_XXS…) or a safetensors precision (fp8_scaled,
// fp8_e4m3fn_scaled, fp16, bf16…). The base identifies the model file (e.g. a
// Wan high-noise expert) independent of which variant was downloaded.
const _VARIANT_RE = /^(.*)[-_]((?:Q\d+(?:_[0-9A-Za-z]+)*|IQ\d+[A-Za-z0-9_]*|fp8(?:_[a-z0-9]+)*|fp16|bf16|fp32|fp4|f16|f32)(?:_scaled)?)\.(gguf|safetensors|ckpt)$/i;
export function variantParts(name) {
const m = _VARIANT_RE.exec(name || "");
return m ? { base: m[1].toLowerCase(), token: m[2].toLowerCase(), ext: m[3].toLowerCase() } : null;
}
// Templates bake one variant; the user may have downloaded another. Remap the
// baked filename to whichever variant of the SAME model file is actually
// installed (read from the loader widget's own combo list). `ref` carries the
// variant chosen by a sibling loader so a multi-expert recipe (Wan high+low
// noise) stays on a single quant when several are on disk. No-op when the baked
// file is already installed or no same-base variant exists.
export function resolveInstalledVariant(baked, installed, ref) {
if (!Array.isArray(installed) || installed.includes(baked)) return baked;
const bp = variantParts(baked);
if (!bp) return baked;
const candidates = installed.filter((o) => {
const op = variantParts(o);
return op && op.base === bp.base && op.ext === bp.ext;
});
if (!candidates.length) return baked;
const pick = (ref?.token && candidates.find((o) => variantParts(o).token === ref.token))
|| candidates.find((o) => o === baked)
|| candidates[0];
if (ref) ref.token = variantParts(pick).token;
return pick;
}
// Checkpoint-loadable archs all share CheckpointLoaderSimple, so they can be swapped
// in place without rebuilding. Flux/Z-Image use different loaders → not swappable here.
const CHECKPOINT_LOADERS = new Set(["CheckpointLoaderSimple", "CheckpointLoader"]);
@@ -241,6 +274,9 @@ export function applyTemplate(pcNode, template, modelFilename) {
? Math.max(0, (pcNode.size?.[0] || 0) + SPAWN_GAP - minOffsetX)
: 0;
// Keeps multi-expert loaders (Wan high+low noise) on a single downloaded quant.
const _variantRef = { token: "" };
const createdNodes = new Array(template.nodes.length).fill(null);
for (let i = 0; i < template.nodes.length; i++) {
const tplNode = template.nodes[i];
@@ -254,6 +290,7 @@ export function applyTemplate(pcNode, template, modelFilename) {
pcNode.pos[1] + tplNode.offset[1],
];
if (tplNode.size) newNode.size = [...tplNode.size];
if (tplNode.title) newNode.title = tplNode.title;
if (tplNode.properties) Object.assign(newNode.properties, tplNode.properties);
graph.add(newNode);
@@ -266,13 +303,29 @@ export function applyTemplate(pcNode, template, modelFilename) {
if (typeof val === "string" && val.includes("{model_name}")) {
val = val.replace(/\{model_name\}/g, slugify(modelName));
}
// Remap a baked GGUF quant / safetensors precision to the variant the
// user actually downloaded — fixes locked multi-expert templates (Wan)
// pointing at a quant that isn't on disk. Reads the loader's installed
// list before ensureComboOption appends the (possibly absent) baked name.
if (typeof val === "string" && /\.(gguf|safetensors|ckpt)$/i.test(val)) {
val = resolveInstalledVariant(val, newNode.widgets[w].options?.values, _variantRef);
}
ensureComboOption(newNode.widgets[w], val);
newNode.widgets[w].value = val;
newNode.widgets[w].callback?.(val);
}
}
if (template._presetNodes) {
// A locked template is a self-contained recipe: it bakes both its model
// filenames (see lockModels guard below) AND its sampler widget values.
// The model config's node presets carry ONE recipe per family (e.g. Wan's
// vanilla 20-step / CFG 3.5), which would clobber a sibling template's
// distinct recipe — applying the vanilla preset over the Fast 4-step /
// CFG 1.0 template left the LightX2V LoRAs running at CFG 3.5 and produced
// burnt video. So skip preset overrides for locked templates; the model-
// settings panel still reads slider RANGES from the config and current
// values from the live (template-baked) nodes.
if (template._presetNodes && !template.lockModels) {
const nodeType = tplNode.type;
const presetWidgets = template._presetNodes[nodeType];
if (presetWidgets && newNode.widgets) {
@@ -289,7 +342,11 @@ export function applyTemplate(pcNode, template, modelFilename) {
createdNodes[i] = newNode;
}
if (modelFilename) {
// Single-loader templates inject the picked model into their loader. MoE/video
// templates (e.g. Wan's high+low noise experts) set lockModels and keep their
// own per-expert filenames — injecting one picked file would clobber the
// second expert and break the pair.
if (modelFilename && !template.lockModels) {
const LOADER_TYPES = new Set([
"CheckpointLoaderSimple", "CheckpointLoader",
"UNETLoader", "DiffusersLoader",
@@ -372,6 +429,13 @@ async function injectTemplatePrompt(pcNode, promptId) {
const recipeHeader = text.split("\n").find((l) => l.trim()) || "";
if (recipeHeader && docText.includes(recipeHeader)) return; // same template re-applied
// A recipe's "Negative Prompt:" section is the model's standard negative
// (e.g. Wan's anti-static string). It sits below {cursor}, so the existing-
// doc path below would drop it — capture it now to re-apply afterward.
const NEG_RE = /(^|\n)[ \t]*negative\s+prompt\s*:/i;
const negMatch = text.match(NEG_RE);
const negSection = negMatch ? text.slice(negMatch.index).replace(/^\n/, "") : "";
const cursorIdx = text.indexOf("{cursor}");
let insertText, anchor;
if (!docText.trim()) {
@@ -394,4 +458,17 @@ async function injectTemplatePrompt(pcNode, promptId) {
} else if (promptWidget) {
promptWidget.value = docText.trim() ? insertText + docText : insertText;
}
// Existing prompt with no negative of its own: append the recipe's standard
// negative (the empty-doc path already inserted it inline).
if (negSection && docText.trim() && !NEG_RE.test(docText)) {
if (view) {
const end = view.state.doc.length;
const tail = view.state.doc.toString().endsWith("\n") ? "" : "\n";
view.dispatch({ changes: { from: end, to: end, insert: tail + negSection } });
} else if (promptWidget) {
const cur = String(promptWidget.value || "");
promptWidget.value = cur + (cur.endsWith("\n") ? "" : "\n") + negSection;
}
}
}
+4 -3
View File
@@ -233,9 +233,9 @@ async function showModelPickerInner(pcNode, anchorEl, opts) {
closeModelPicker();
showDownloadModal(civitaiResult, pcNode, anchorEl);
},
onShowCatalogDownload: (catalogEntry) => {
onShowCatalogDownload: (catalogEntry, initialPrecision) => {
closeModelPicker();
showCatalogDownloadModal(catalogEntry, pcNode, anchorEl);
showCatalogDownloadModal(catalogEntry, pcNode, anchorEl, initialPrecision);
},
onClose: () => closeModelPicker(),
applyTemplate: (tpl, filename, presetNodes) => {
@@ -304,7 +304,7 @@ function closeCatalogDownloadModal() {
}
}
async function showCatalogDownloadModal(catalogEntry, pcNode, indicatorEl) {
async function showCatalogDownloadModal(catalogEntry, pcNode, indicatorEl, initialPrecision = null) {
closeCatalogDownloadModal();
ensureCSS();
@@ -315,6 +315,7 @@ async function showCatalogDownloadModal(catalogEntry, pcNode, indicatorEl) {
_activeCatalogDownload = mod.mountCatalogDownloadModal(_catalogContainer, {
catalogEntry,
initialPrecision,
onClose: () => closeCatalogDownloadModal(),
onModelReady: (filename) => {
closeCatalogDownloadModal();
+61 -5
View File
@@ -31,7 +31,7 @@ export const FAMILIES = {
zimage: [{ id: "zimage_base", label: "Z-Image Base" }, { id: "zimage_turbo", label: "Z-Image Turbo" }],
qwen_image: [{ id: "qwen_image", label: "Qwen Image" }],
qwen_edit: [{ id: "qwen_edit", label: "Qwen Edit" }, { id: "qwen_aio", label: "Qwen AIO" }],
wan22: [{ id: "wan22_t2v", label: "Wan T2V 14B" }, { id: "wan22_i2v", label: "Wan I2V 14B" }, { id: "wan22_5b", label: "Wan 5B" }],
wan22: [{ id: "wan22_i2v", label: "Wan I2V 14B" }, { id: "wan22_i2v_gguf", label: "Wan I2V 14B (GGUF)" }, { id: "wan22_t2v", label: "Wan T2V 14B" }, { id: "wan22_t2v_gguf", label: "Wan T2V 14B (GGUF)" }, { id: "wan22_5b", label: "Wan TI2V 5B" }, { id: "wan22_5b_gguf", label: "Wan TI2V 5B (GGUF)" }],
ltx: [{ id: "ltx23", label: "LTX 2.3" }],
hunyuan_video: [{ id: "hunyuan_video_15", label: "HunyuanVideo 1.5" }],
hidream: [{ id: "hidream", label: "HiDream-I1" }],
@@ -215,7 +215,37 @@ export const FLUX_NODES = {
},
};
// Ideogram 4 tuning nodes. Its t2i graph samples through PromptChain_IdeogramSampler
// (a SamplerCustomAdvanced+VAEDecode drop-in), fed by these. Surfaced in the model
// Settings panel like FLUX_NODES so an Ideogram model has real controls instead of
// an empty pane. Resolution stays on the EmptyFlux2LatentImage row (LATENT_NODES);
// the scheduler's own width/height are intentionally not exposed here to avoid a
// second resolution row.
export const IDEOGRAM_NODES = {
Ideogram4Scheduler: {
label: "Scheduler",
widgets: {
steps: { type: "slider", min: 1, max: 200, step: 1 },
},
},
DualModelGuider: {
label: "Guidance",
widgets: {
cfg: { type: "slider", min: 0, max: 30, step: 0.1 },
},
},
CFGOverride: {
label: "CFG Override",
widgets: {
cfg: { type: "slider", min: 0, max: 30, step: 0.1 },
start_percent: { type: "slider", min: 0, max: 1, step: 0.01 },
end_percent: { type: "slider", min: 0, max: 1, step: 0.01 },
},
},
};
const FLUX_NODE_TYPES = new Set(Object.keys(FLUX_NODES));
const IDEOGRAM_NODE_TYPES = new Set(Object.keys(IDEOGRAM_NODES));
const SAMPLER_INCLUDES = ["KSampler"];
const LATENT_NODE_TYPES = new Set(Object.keys(LATENT_NODES));
const CLIP_NODE_TYPES = new Set(Object.keys(CLIP_NODES));
@@ -540,6 +570,7 @@ export function detectSupportedNodes(pcNode) {
const seenIds = new Set();
let ksamplerNode = null;
let fluxSamplerNode = null;
let ideogramSamplerNode = null;
for (const node of downstream) {
if (seenIds.has(node.id)) continue;
@@ -551,6 +582,16 @@ export function detectSupportedNodes(pcNode) {
continue;
}
// Ideogram samples through PromptChain_IdeogramSampler (a SamplerCustomAdvanced
// drop-in). Reuse the Flux pivot machinery (latent/sampler/noise upstream walk)
// and additionally walk the Ideogram-only tuning nodes below.
if (cls === "PromptChain_IdeogramSampler") {
seenIds.add(node.id);
ideogramSamplerNode = node;
fluxSamplerNode = node;
continue;
}
// ControlNet branches: one deferred section per branch, keyed by control
// type (depth/canny/…) so ranges + recommended don't collide when stacked.
if (cls === "ControlNetApplyAdvanced") {
@@ -575,7 +616,9 @@ export function detectSupportedNodes(pcNode) {
continue;
}
for (const pattern of SAMPLER_INCLUDES) {
if (cls.includes(pattern)) {
// KSamplerSelect just PICKS a sampler (outputs SAMPLER); it's not a real
// sampler pivot — exclude it so it never gets adopted as the latent anchor.
if (cls.includes(pattern) && cls !== "KSamplerSelect") {
seenIds.add(node.id);
detected.push({ node, config: SUPPORTED_NODES.KSampler, type: cls });
ksamplerNode = node;
@@ -594,6 +637,19 @@ export function detectSupportedNodes(pcNode) {
}
}
// Ideogram-only tuning nodes upstream of the Ideogram sampler (steps / guidance /
// cfg-override). Resolution + sampler + noise are already picked up via the Flux
// walk above and the latent walk below.
if (ideogramSamplerNode) {
for (const cls of IDEOGRAM_NODE_TYPES) {
const n = findUpstreamByType(ideogramSamplerNode, new Set([cls]));
if (n && !seenIds.has(n.id)) {
seenIds.add(n.id);
detected.push({ node: n, config: IDEOGRAM_NODES[cls], type: cls });
}
}
}
const pivotNode = ksamplerNode || fluxSamplerNode;
if (pivotNode) {
if (!fluxSamplerNode) {
@@ -2348,7 +2404,7 @@ export function resolveResolutionTicks(detected) {
}
if (!inputLongest) {
const latEntry = detected.find(d =>
d.type === "EmptyLatentImage" || d.type === "EmptySD3LatentImage"
d.type === "EmptyLatentImage" || d.type === "EmptySD3LatentImage" || d.type === "EmptyFlux2LatentImage"
);
if (latEntry) {
const wW = readWidgetValue(latEntry.node, "width");
@@ -2728,9 +2784,9 @@ export async function showModelModal(pcNode, modelInfo, anchorEl, { tab = null }
}
return items;
},
onShowCatalogDownload: (entry) => {
onShowCatalogDownload: (entry, initialPrecision) => {
closeModelModal();
showCatalogDownloadModal(entry, pcNode, anchorEl);
showCatalogDownloadModal(entry, pcNode, anchorEl, initialPrecision);
},
// Editor reference for prompts tab
getEditor: () => pcNode._pcrEditor,
+29
View File
@@ -0,0 +1,29 @@
// Shared helper for off-screen render graphs (extend / smooth / repose).
//
// ComfyUI keys execution outputs by node id GLOBALLY: app.ts runs
// nodeOutputStore.setNodeOutputsByExecutionId(String(detail.node), output)
// and then paints that output onto whatever node in the VISIBLE graph carries
// the same id. A background render graph numbered from 1 (EMPTY_WORKFLOW starts
// at last_node_id: 0) therefore collides with low-id live nodes — e.g. a 5-node
// Smooth graph whose SaveVideo is id 5 lands its broken-looking video frame on
// the user's PromptChain node when that node also happens to be id 5. Numbering
// our render nodes far above every live id keeps execution results off canvas.
import { app } from "../../../scripts/app.js";
const OFFSCREEN_GAP = 1_000_000;
// First id to assign in an off-screen graph: above every id the live canvas
// could report, so ComfyUI's id-keyed output painter never finds a visible
// match. Set graph.last_node_id to this BEFORE adding nodes (LGraph.add then
// hands out base+1, base+2, …).
export function offscreenIdBase() {
let maxLiveId = 0;
try {
for (const g of [app?.graph, app?.rootGraph, app?.canvas?.graph]) {
const n = Number(g?.last_node_id);
if (Number.isFinite(n) && n > maxLiveId) maxLiveId = n;
}
} catch { /* app not ready → the constant gap alone is collision-proof */ }
return maxLiveId + OFFSCREEN_GAP;
}
+62
View File
@@ -0,0 +1,62 @@
// Shared registry of live Region Box (PromptChain_RegionBox) nodes.
//
// region-box.js populates this as Region Box nodes mount/unmount; main.js reads
// it so each PromptChain node's menubar can gate the "pop the Region Box into a
// panel" button (enabled only when a Region Box exists) and so the panel knows
// which node to dock. A single shared module instead of a window global keeps it
// deterministic regardless of which extension file the loader runs first.
//
// Twin of pose-registry.js — separate so the two panels gate independently.
// Event-driven: subscribers are notified on add/remove (never polled).
const nodes = new Set();
const listeners = new Set();
let lastActive = null;
function notify() {
for (const cb of listeners) {
try { cb(); } catch (e) { console.error("[PromptChain] region-box-registry listener error", e); }
}
}
export const regionBoxRegistry = {
// Called by region-box.js on mount.
add(node) {
nodes.add(node);
lastActive = node;
notify();
},
// Called by region-box.js on teardown.
remove(node) {
const had = nodes.delete(node);
if (lastActive === node) lastActive = null;
if (had) notify();
},
// Called when a Region Box canvas is interacted with, so "which node does the
// panel dock" follows the one you last touched. Quiet by design — switching
// focus shouldn't yank an already-open panel onto a different node.
touch(node) {
if (nodes.has(node)) lastActive = node;
},
// The canvas finished mounting (enterDock now exists). add() fired its notify
// earlier; re-notify so an already-open panel can dock now.
signalReady(node) {
if (nodes.has(node)) notify();
},
all() {
return [...nodes];
},
// The node a freshly-opened panel should host: last-interacted if still alive,
// else any live one.
getActive() {
if (lastActive && lastActive._pcrAlive && nodes.has(lastActive)) return lastActive;
for (const n of nodes) if (n._pcrAlive) return n;
return null;
},
get count() { return nodes.size; },
// Returns an unsubscribe fn. Fires on add/remove (gating + open-panel refresh).
subscribe(cb) {
listeners.add(cb);
return () => listeners.delete(cb);
},
};
+185
View File
@@ -0,0 +1,185 @@
// Shared region-name binding between a region-mask PRODUCER (the 3D Poser, the
// Region Box canvas) and a regional CONSUMER (Attention Couple / Regional
// Conditioning) wired to a PromptChain node.
//
// A producer emits MASKS (one row per region entity, in entity order) plus a
// POSE_JSON entity list; a consumer binds each `$name{}` block in the prompt to
// its mask row BY NAME. These helpers keep the bound prompt stocked with one
// `$name{}` block per producer region and travel renames into the prompt.
//
// Generalized fork of the Poser's own (AttentionCouple-only) helpers so the Flux
// RegionalConditioning path binds region names too, and so the seed budget is
// per-producer (counting only the caller's own names) — the Poser's inline copy
// is intentionally left untouched.
import { app } from "../../../scripts/app.js";
import { getLink } from "./slot-utils.js";
import { setEditorContent } from "./editor.js";
// PromptChain consumers that take a `regions` JSON and bind each $name{} block
// to a region. AttentionCouple patches SDXL cross-attention; RegionalConditioning
// attaches the mask inside the conditioning (Flux + tiled upscale); IdeogramCaption
// places each region at its box's caption coordinates (Ideogram has no mask, so it
// reads the box from POSE_JSON, not MASKS). A producer feeds any of these.
export const REGIONAL_CONSUMERS = new Set([
"PromptChain_AttentionCouple",
"PromptChain_RegionalConditioning",
"PromptChain_IdeogramCaption",
]);
// Producer outputs that can reach a consumer: MASKS for the mask consumers,
// POSE_JSON for the Ideogram caption (mask-less).
const PRODUCER_OUTPUTS = ["MASKS", "POSE_JSON"];
// $-block names are matched as \w+ by the compiler; drop any other char so a
// stray metachar in a label can't corrupt the seed/rename regexes. (Labels are
// already constrained to word-chars at the canvas, so this is a safety net.)
function safeName(name) {
return String(name || "").replace(/[^\w]/g, "");
}
// Trace a producer node's MASKS/POSE_JSON output -> a regional consumer -> its
// `regions` input -> the PromptChain node feeding it. Returns { consumer, pc }
// or {}.
export function traceRegionConsumer(node) {
const graph = app.graph;
if (!graph) return {};
for (const outName of PRODUCER_OUTPUTS) {
const out = node.outputs?.find((o) => o.name === outName);
if (!out?.links?.length) continue;
for (const linkId of out.links) {
const link = getLink(graph, linkId);
const consumer = link && graph.getNodeById(link.target_id);
if (!REGIONAL_CONSUMERS.has(consumer?.comfyClass)) continue;
const regIn = consumer.inputs?.find((i) => i.name === "regions");
const rlink = regIn?.link != null ? getLink(graph, regIn.link) : null;
const pc = rlink && graph.getNodeById(rlink.origin_id);
if (pc?.comfyClass === "PromptChain_PromptChain") return { consumer, pc };
}
}
return {};
}
// Seed a `$name { }` block into the bound prompt for every region name that has
// no block yet (add-only, case-insensitive to match the server's name binding).
// Per-producer by construction: it only ever seeds the names passed in, so a
// Poser sharing the same prompt can't make it over/under-seed. Inserts ABOVE the
// negative section so a new block never lands inside the negative prompt.
export function reconcileRegionBlocks(node, names) {
if (!names?.length) return;
const { pc } = traceRegionConsumer(node);
if (!pc?._pcrEditor) return;
const text = pc._pcrEditor.state.doc.toString();
const missing = names.filter(
(n) => safeName(n) && !new RegExp("\\$" + safeName(n) + "\\s*\\{", "i").test(text));
if (!missing.length) return;
const blocks = missing.map((name) => "$" + safeName(name) + " {\n\n}").join("\n\n");
const neg = text.match(/negative\s+prompt\s*:/i);
if (neg) {
const head = text.slice(0, neg.index);
setEditorContent(pc._pcrEditor,
(head.trim() ? head.replace(/\s*$/, "\n\n") : "") + blocks + "\n\n" + text.slice(neg.index));
} else {
setEditorContent(pc._pcrEditor, text + (text.trim() ? "\n\n" : "") + blocks);
}
}
// Read a `$name { body }` block's body to its MATCHING close brace (brace-depth
// tracked), skipping past any EMPTY same-name blocks to the first POPULATED one.
// Depth tracking keeps a body's own {a|b} wildcard/alternation braces intact, so
// what the user typed survives the copy — the server's compile grammar is itself
// non-greedy, but preserving the authored text is strictly safer than truncating
// it here. Returns "" when the name has no populated block.
function extractRegionBody(text, sn) {
const open = new RegExp("\\$" + sn + "\\s*\\{", "gi");
let m;
while ((m = open.exec(text)) !== null) {
let depth = 1, i = m.index + m[0].length;
const start = i;
for (; i < text.length && depth > 0; i++) {
if (text[i] === "{") depth++;
else if (text[i] === "}") depth--;
}
if (depth === 0) {
const body = text.slice(start, i - 1).trim();
if (body) return body;
}
}
return "";
}
// Read each named region's typed body out of the bound prompt, so a copied
// character's prompt can travel with it to another scene. Null-prototype map so a
// region literally named "__proto__" can't poison the result. Keyed by the name
// AS PASSED (the entity's custom name), not the escaped form.
export function readRegionBlockBodies(node, names) {
const out = Object.create(null);
if (!names?.length) return out;
const { pc } = traceRegionConsumer(node);
if (!pc?._pcrEditor) return out;
const text = pc._pcrEditor.state.doc.toString();
for (const name of names) {
const sn = safeName(name);
if (!sn) continue;
const body = extractRegionBody(text, sn);
if (body) out[name] = body;
}
return out;
}
// Seed `$name { body }` blocks carrying the typed tags of region entities pasted
// from another scene — like reconcileRegionBlocks but writes each block's BODY
// (not an empty stub) and is keyed per pasted entity, so a copied character's
// prompt travels with it. Add-only/case-insensitive: never clobbers a block the
// destination already holds under that name. Returns true once the bound editor
// exists (injection done, or nothing fresh to add) so the caller can stop
// retrying; false while the editor isn't built yet (caller keeps it pending).
export function seedRegionBlocks(node, entries) {
if (!entries?.length) return true;
const { pc } = traceRegionConsumer(node);
if (!pc) return true; // not region-wired → nothing to inject into; caller must stop retrying
if (!pc._pcrEditor) return false; // consumer wired but its editor isn't built yet → retry next capture
const text = pc._pcrEditor.state.doc.toString();
const fresh = entries.filter(
(e) => safeName(e.name) && !new RegExp("\\$" + safeName(e.name) + "\\s*\\{", "i").test(text));
if (!fresh.length) return true;
const blocks = fresh.map((e) => {
const body = (e.body || "").trim();
return "$" + safeName(e.name) + " {\n" + (body ? body + "\n" : "\n") + "}";
}).join("\n\n");
// Carried region blocks LEAD the prompt: prepend at the very top so a pasted
// character's $name{ tags } is the first thing in the prompt (above the quality
// tags). Top placement is trivially above the negative section, so the
// negative-marker guard reconcileRegionBlocks needs isn't required here.
const rest = text.replace(/^\s+/, "");
setEditorContent(pc._pcrEditor, blocks + (rest ? "\n\n" + rest : ""));
return true;
}
// Rewrite every `$oldName {` block to `$newName {` so the user's typed tags
// travel with a box rename. No-op when no such block exists (reconcile seeds the
// new name instead on the next change).
export function renameRegionBlock(node, oldName, newName) {
const oldN = safeName(oldName), newN = safeName(newName);
if (!oldN || !newN || oldN === newN) return;
const { pc } = traceRegionConsumer(node);
if (!pc?._pcrEditor) return;
const text = pc._pcrEditor.state.doc.toString();
const re = new RegExp("\\$" + oldN + "(\\s*\\{)", "gi");
if (!re.test(text)) return;
re.lastIndex = 0;
setEditorContent(pc._pcrEditor, text.replace(re, (m, brace) => "$" + newN + brace));
}
// Region->entity binding is by NAME on the server, which needs the entity list —
// wire POSE_JSON into the consumer's `pose` input if it isn't already. Connects
// ONLY when the slot is empty: never steals `pose` from a 3D Poser that already
// owns it (whoever owns `pose` defines the entity list for the whole prompt).
export function ensureConsumerPoseWire(node) {
const { consumer } = traceRegionConsumer(node);
if (!consumer) return;
const poseIn = consumer.inputs?.findIndex((i) => i.name === "pose");
if (poseIn == null || poseIn < 0 || consumer.inputs[poseIn].link != null) return;
const poseOut = node.outputs?.findIndex((o) => o.name === "POSE_JSON");
if (poseOut >= 0) node.connect(poseOut, consumer, poseIn);
}
+2
View File
@@ -9,6 +9,7 @@
import { api } from "../../../scripts/api.js";
import { REPOSE_RECIPES } from "./repose-recipes.js";
import { offscreenIdBase } from "./offscreen-graph.js";
const EMPTY_WORKFLOW = { last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 };
@@ -123,6 +124,7 @@ export async function buildReposeGraph(opts) {
const workflowId = freshWorkflowId();
const graph = new LG.LGraph(structuredClone({ ...EMPTY_WORKFLOW, id: workflowId }));
graph.last_node_id = offscreenIdBase(); // render-node ids must stay off the live canvas — see offscreen-graph.js
const created = new Array(template.nodes.length).fill(null);
let poserLoader = null;
+147
View File
@@ -0,0 +1,147 @@
// "Smooth" background runner — RIFE interpolation off-screen, queued, recorded as
// a lineage CHILD of the source video. Mirrors extend-background.js.
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { recordGeneration, externallyTrackedPrompts, armExternalQueue, disarmExternalQueue } from "./history.js";
import { buildSmoothGraph } from "./smooth-from-image.js";
function toast(severity, detail, life = 8000) {
app.extensionManager?.toast?.add({ severity, summary: "Smooth", detail, life });
}
function createTracker() {
const listeners = new Set();
const tracker = {
state: { phase: "building" },
subscribe(fn) { listeners.add(fn); fn(tracker.state); return () => listeners.delete(fn); },
emit(state) { tracker.state = state; for (const fn of listeners) { try { fn(state); } catch {} } },
};
return tracker;
}
function viewUrl(img) {
if (!img?.filename) return "";
const q = new URLSearchParams({ filename: img.filename, subfolder: img.subfolder || "", type: img.type || "output" });
return api.apiURL ? api.apiURL(`/view?${q}`) : `/view?${q}`;
}
// opts: { sourceVideoFilename, multiplier, outFps, parentFilename }
export function runSmoothInBackground(opts) {
const tracker = createTracker();
const run = { cancelled: false, promptId: null, executionStarted: false, cleanup: null };
tracker.cancel = async () => {
if (run.cancelled) return;
run.cancelled = true;
tracker.emit({ phase: "cancelled" });
toast("info", "Smooth cancelled.");
try {
if (run.promptId) {
if (run.executionStarted) await api.interrupt();
else {
await api.fetchApi("/queue", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete: [run.promptId] }),
});
run.cleanup?.();
}
}
} catch (e) { console.warn("[Smooth] cancel request failed", e); }
};
(async () => {
tracker.emit({ phase: "building" });
// Whole opts through — an allowlist here dropped the Advanced knobs
// (ckpt/scale_factor/ensemble/fast_mode) the modal sends.
const built = buildSmoothGraph({ ...opts });
if (!built) { tracker.emit({ phase: "error", message: "couldn't build the smooth graph (is the RIFE pack installed?)" }); return; }
const { graph, workflowId } = built;
if (run.cancelled) return;
tracker.emit({ phase: "queueing" });
const serialized = await app.graphToPrompt(graph);
armExternalQueue();
let promptId = null;
try {
const previewMethod = app.extensionManager?.setting?.get?.("Comfy.Execution.PreviewMethod")
?? app.ui?.settings?.getSettingValue?.("Comfy.Execution.PreviewMethod") ?? "default";
const res = await api.queuePrompt(0, { output: serialized.output, workflow: serialized.workflow }, { previewMethod });
promptId = res?.prompt_id || null;
} catch (e) {
disarmExternalQueue();
const message = e?.response?.error?.message || e?.message || "queue rejected the prompt";
tracker.emit({ phase: "error", message });
toast("error", `Smooth failed to queue: ${message}`);
return;
}
if (promptId) externallyTrackedPrompts.add(promptId);
disarmExternalQueue();
run.promptId = promptId;
if (!promptId) { tracker.emit({ phase: "error", message: "queue returned no prompt id" }); return; }
if (run.cancelled) {
try {
await api.fetchApi("/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: [promptId] }) });
} catch {}
externallyTrackedPrompts.delete(promptId);
return;
}
let lastImage = null, lastPreview = null;
const onStart = ({ detail }) => { if (detail?.prompt_id === promptId) run.executionStarted = true; };
const onProgress = ({ detail }) => {
if (detail?.prompt_id !== promptId || run.cancelled) return;
run.executionStarted = true;
tracker.emit({ ...tracker.state, phase: "running", value: detail?.value || 0, max: detail?.max || 0 });
};
const onPreview = ({ detail }) => {
if (run.cancelled || !run.executionStarted || !(detail instanceof Blob)) return;
if (lastPreview) URL.revokeObjectURL(lastPreview);
lastPreview = URL.createObjectURL(detail);
tracker.emit({ ...tracker.state, phase: "running", previewUrl: lastPreview });
};
const onExecuted = ({ detail }) => {
if (detail?.prompt_id !== promptId || !detail?.output?.images?.length) return;
lastImage = detail.output.images[detail.output.images.length - 1];
};
const cleanup = () => {
api.removeEventListener("execution_start", onStart);
api.removeEventListener("progress", onProgress);
api.removeEventListener("b_preview", onPreview);
api.removeEventListener("executed", onExecuted);
api.removeEventListener("execution_success", onSuccess);
api.removeEventListener("execution_error", onError);
api.removeEventListener("execution_interrupted", onError);
if (promptId) externallyTrackedPrompts.delete(promptId);
};
run.cleanup = cleanup;
const onSuccess = async ({ detail }) => {
if (detail?.prompt_id !== promptId) return;
cleanup();
if (!lastImage) { tracker.emit({ phase: "error", message: "smooth produced no video" }); return; }
const entry = await recordGeneration(workflowId, lastImage.filename, lastImage.subfolder, "output", {
parent_filename: opts.parentFilename || "",
});
tracker.emit({ phase: "done", resultUrl: viewUrl(lastImage), resultHash: entry?.hash || null, entry });
};
const onError = ({ detail }) => {
if (detail?.prompt_id !== promptId) return;
cleanup();
tracker.emit({ phase: "error", message: detail?.exception_message || "smooth render failed" });
};
api.addEventListener("execution_start", onStart);
api.addEventListener("progress", onProgress);
api.addEventListener("b_preview", onPreview);
api.addEventListener("executed", onExecuted);
api.addEventListener("execution_success", onSuccess);
api.addEventListener("execution_error", onError);
api.addEventListener("execution_interrupted", onError);
tracker.emit({ phase: "running" });
})().catch((e) => {
console.error("[Smooth] background run failed", e);
tracker.emit({ phase: "error", message: e?.message || "unexpected failure" });
toast("error", `Smooth failed: ${e?.message || e}`);
});
return tracker;
}
+80
View File
@@ -0,0 +1,80 @@
// "Smooth" graph builder — RIFE frame interpolation as a viewer post-process.
// Builds an off-screen graph: LoadVideo -> GetVideoComponents -> RIFE VFI ->
// CreateVideo (fps x multiplier) -> SaveVideo. All core ComfyUI video nodes
// except RIFE VFI (Fannovel16 ComfyUI-Frame-Interpolation, bundled as the
// "FrameInterpolation" pack). The source video is staged in input/ by
// /promptchain/extend/prepare (reused) so LoadVideo can consume it.
import { offscreenIdBase } from "./offscreen-graph.js";
const EMPTY_WORKFLOW = { last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 };
function freshWorkflowId() {
return globalThis.crypto?.randomUUID ? crypto.randomUUID() : `${Date.now().toString(16)}-smooth`;
}
function setNamedWidget(node, name, value) {
const w = node?.widgets?.find((x) => x.name === name);
if (!w) return false;
if (Array.isArray(w.options?.values) && value != null && !w.options.values.includes(value)) {
w.options.values.push(value); // a freshly-staged input video isn't in the combo yet
}
w.value = value;
w.callback?.(value);
return true;
}
// True only when every node the Smooth graph needs is registered (RIFE pack +
// core video nodes). The caller gates the install offer on RIFE VFI specifically.
export function smoothNodesAvailable() {
const reg = window.LiteGraph?.registered_node_types || {};
return ["RIFE VFI", "LoadVideo", "GetVideoComponents", "CreateVideo", "SaveVideo"].every((t) => reg[t]);
}
// opts: { sourceVideoFilename, multiplier, outFps }
export function buildSmoothGraph(opts) {
const LG = window.LiteGraph;
if (!LG || !smoothNodesAvailable()) return null;
const workflowId = freshWorkflowId();
const graph = new LG.LGraph(structuredClone({ ...EMPTY_WORKFLOW, id: workflowId }));
graph.last_node_id = offscreenIdBase(); // render-node ids must stay off the live canvas — see offscreen-graph.js
const mk = (t) => { const n = LG.createNode(t); if (n) graph.add(n); return n; };
const load = mk("LoadVideo");
const comp = mk("GetVideoComponents");
const rife = mk("RIFE VFI");
const create = mk("CreateVideo");
const save = mk("SaveVideo");
if (!load || !comp || !rife || !create || !save) return null;
setNamedWidget(load, "file", opts.sourceVideoFilename);
setNamedWidget(rife, "ckpt_name", opts.ckptName || "rife49.pth");
setNamedWidget(rife, "multiplier", opts.multiplier || 2);
// Quality/VRAM knobs — all optional, RIFE's own defaults if omitted. A picked
// ckpt that isn't on disk auto-downloads on first run (the node handles it).
if (opts.scaleFactor != null) setNamedWidget(rife, "scale_factor", opts.scaleFactor);
if (opts.ensemble != null) setNamedWidget(rife, "ensemble", opts.ensemble);
if (opts.fastMode != null) setNamedWidget(rife, "fast_mode", opts.fastMode);
if (!setNamedWidget(create, "fps", opts.outFps || 32) && create.widgets?.[1]) {
create.widgets[1].value = opts.outFps || 32; // positional fallback (images, fps, audio)
}
if (!setNamedWidget(save, "filename_prefix", "video/PromptChain") && save.widgets?.[0]) {
save.widgets[0].value = "video/PromptChain";
}
// wiring: VIDEO -> components; images -> RIFE; interpolated -> CreateVideo;
// carry the original audio through; CreateVideo -> SaveVideo.
try {
load.connect(0, comp, 0); // VIDEO -> GetVideoComponents.video
comp.connect(0, rife, 0); // images -> RIFE.frames
rife.connect(0, create, 0); // interpolated images -> CreateVideo.images
comp.connect(1, create, 1); // audio -> CreateVideo.audio (optional)
create.connect(0, save, 0); // VIDEO -> SaveVideo.video
} catch (e) {
console.warn("[Smooth] wiring failed", e);
return null;
}
return { graph, workflowId };
}
@@ -507,10 +507,29 @@ function GeneratedGallery($$anchor, $$props) {
}
function retryThumb(e) {
const img = e.currentTarget;
if (img.dataset.pcrRetried) return;
img.dataset.pcrRetried = "1";
img.src = img.src + (img.src.includes("?") ? "&" : "?") + "r=1";
const n = +img.dataset.pcrRetry || 0;
if (n >= 3) return;
img.dataset.pcrRetry = String(n + 1);
const u = new URL(img.src, location.href);
u.searchParams.set("r", String(n + 1));
img.src = u.toString();
}
function healBrokenThumbs() {
if (!galleryEl) return;
for (const img of galleryEl.querySelectorAll("img")) {
if (img.complete && img.naturalWidth === 0) {
img.dataset.pcrRetry = "0";
const u = new URL(img.src, location.href);
u.searchParams.set("r", "heal");
img.src = u.toString();
}
}
}
user_effect(() => {
const onRecorded = () => healBrokenThumbs();
window.addEventListener("promptchain:generation-recorded", onRecorded);
return () => window.removeEventListener("promptchain:generation-recorded", onRecorded);
});
var fragment = root();
var div = first_child(fragment);
var node = child(div);
@@ -782,4 +801,4 @@ export {
clampGalleryRowHeight as c,
zoomGalleryRowHeight as z
};
//# sourceMappingURL=GeneratedGallery-Dw0CC3-N.js.map
//# sourceMappingURL=GeneratedGallery-D5XbkMk7.js.map
@@ -283,7 +283,7 @@ function ModeMenu($$anchor, $$props) {
delegate(["click"]);
var root_1 = from_html(`<span class="pcr-menubar-mode-arrow svelte-vzwh12"></span>`);
var root_2 = from_html(`<span title="AI Assistant (experimental)" data-action="assistant"></span>`);
var root_3 = from_html(`<span data-action="pose"></span> <span title="Toggle image preview" data-action="image"></span> <span title="Toggle output panel (Ctrl+\`)" data-action="output"></span>`, 1);
var root_3 = from_html(`<span data-action="pose"></span> <span data-action="region"></span> <span title="Toggle image preview" data-action="image"></span> <span title="Toggle output panel (Ctrl+\`)" data-action="output"></span>`, 1);
var root_4 = from_html(`<span class="pcr-menubar-btn svelte-vzwh12" title="Fullscreen editor" data-action="maximize"></span> <span title="Collapse editor" data-action="collapse"></span>`, 1);
var root = from_html(`<div><div class="pcr-menubar-actions-left"></div> <div style="display:flex;align-items:center;min-width:0;"><div class="pcr-menubar-mode svelte-vzwh12"><span class="pcr-menubar-mode-label svelte-vzwh12"> </span> <!></div> <div class="pcr-menubar-actions svelte-vzwh12"><!> <span title="Lock output" data-action="lock"></span> <span title="Disable node" data-action="disable"></span> <!> <!></div></div></div> <!>`, 1);
function Menubar($$anchor, $$props) {
@@ -304,6 +304,7 @@ function Menubar($$anchor, $$props) {
}), onToggleImage = prop($$props, "onToggleImage", 3, () => {
}), onToggleAssistant = prop($$props, "onToggleAssistant", 3, () => {
}), onTogglePose = prop($$props, "onTogglePose", 3, () => {
}), onToggleRegion = prop($$props, "onToggleRegion", 3, () => {
}), onOpenFullscreen = prop($$props, "onOpenFullscreen", 3, () => {
}), onResetIterate = prop($$props, "onResetIterate", 3, null), docDropdownEl = prop($$props, "docDropdownEl", 3, null);
let docSlot;
@@ -393,6 +394,8 @@ function Menubar($$anchor, $$props) {
if (get(aiAssistantEnabled)) onToggleAssistant()();
} else if (action2 === "pose") {
if ($$props.shared.hasPoseStudio) onTogglePose()();
} else if (action2 === "region") {
if ($$props.shared.hasRegionBox) onToggleRegion()();
} else if (action2 === "collapse") onToggleCollapse()();
else if (action2 === "maximize") onOpenFullscreen()();
}
@@ -475,13 +478,18 @@ function Menubar($$anchor, $$props) {
var span_6 = sibling(span_5, 2);
let classes_5;
let styles_6;
html(span_6, () => '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>', true);
action(span_6, ($$node, $$action_arg) => actionClick == null ? void 0 : actionClick($$node, $$action_arg), () => "image");
html(span_6, () => '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M3 5v4h2V5h4V3H5c-1.1 0-2 .9-2 2zm2 10H3v4c0 1.1.9 2 2 2h4v-2H5v-4zm14 4h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4zm0-16h-4v2h4v4h2V5c0-1.1-.9-2-2-2zM7 7v10h10V7H7zm8 8H9V9h6v6z"/></svg>', true);
action(span_6, ($$node, $$action_arg) => actionClick == null ? void 0 : actionClick($$node, $$action_arg), () => "region");
var span_7 = sibling(span_6, 2);
let classes_6;
let styles_7;
html(span_7, () => '<svg width="18" height="18" viewBox="0 -960 960 960" fill="currentColor"><path d="M400-280h160v-80H400v80Zm0-160h280v-80H400v80ZM280-600h400v-80H280v80Zm200 120ZM80-80v-80h102q-48-23-77.5-68T75-330q0-79 55.5-134.5T265-520v80q-45 0-77.5 32T155-330q0 39 24 69t61 38v-97h80v240H80Zm320-40v-80h360v-560H200v160h-80v-160q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H400Z"/></svg>', true);
action(span_7, ($$node, $$action_arg) => actionClick == null ? void 0 : actionClick($$node, $$action_arg), () => "output");
html(span_7, () => '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>', true);
action(span_7, ($$node, $$action_arg) => actionClick == null ? void 0 : actionClick($$node, $$action_arg), () => "image");
var span_8 = sibling(span_7, 2);
let classes_7;
let styles_8;
html(span_8, () => '<svg width="18" height="18" viewBox="0 -960 960 960" fill="currentColor"><path d="M400-280h160v-80H400v80Zm0-160h280v-80H400v80ZM280-600h400v-80H280v80Zm200 120ZM80-80v-80h102q-48-23-77.5-68T75-330q0-79 55.5-134.5T265-520v80q-45 0-77.5 32T155-330q0 39 24 69t61 38v-97h80v240H80Zm320-40v-80h360v-560H200v160h-80v-160q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H400Z"/></svg>', true);
action(span_8, ($$node, $$action_arg) => actionClick == null ? void 0 : actionClick($$node, $$action_arg), () => "output");
template_effect(() => {
classes_4 = set_class(span_5, 1, "pcr-menubar-btn svelte-vzwh12", null, classes_4, {
"pcr-menubar-btn-active": $$props.shared.posePanelVisible,
@@ -489,10 +497,16 @@ function Menubar($$anchor, $$props) {
});
set_attribute(span_5, "title", $$props.shared.hasPoseStudio ? "Toggle 3D Poser panel" : "Add a 3D Poser node to use this");
styles_5 = set_style(span_5, "", styles_5, { color: $$props.shared.posePanelVisible ? "#ff8c1a" : "" });
classes_5 = set_class(span_6, 1, "pcr-menubar-btn svelte-vzwh12", null, classes_5, { "pcr-menubar-btn-active": $$props.shared.imagePanelVisible });
styles_6 = set_style(span_6, "", styles_6, { color: $$props.shared.imagePanelVisible ? "#4bb949" : "" });
classes_6 = set_class(span_7, 1, "pcr-menubar-btn svelte-vzwh12", null, classes_6, { "pcr-menubar-btn-active": $$props.shared.outputPanelOpen });
styles_7 = set_style(span_7, "", styles_7, { color: $$props.shared.outputPanelOpen ? "#4fc3f7" : "" });
classes_5 = set_class(span_6, 1, "pcr-menubar-btn svelte-vzwh12", null, classes_5, {
"pcr-menubar-btn-active": $$props.shared.regionPanelVisible,
"pcr-menubar-btn-disabled": !$$props.shared.hasRegionBox
});
set_attribute(span_6, "title", $$props.shared.hasRegionBox ? "Toggle Region Box panel" : "Add a Region Box node to use this");
styles_6 = set_style(span_6, "", styles_6, { color: $$props.shared.regionPanelVisible ? "#42b9c4" : "" });
classes_6 = set_class(span_7, 1, "pcr-menubar-btn svelte-vzwh12", null, classes_6, { "pcr-menubar-btn-active": $$props.shared.imagePanelVisible });
styles_7 = set_style(span_7, "", styles_7, { color: $$props.shared.imagePanelVisible ? "#4bb949" : "" });
classes_7 = set_class(span_8, 1, "pcr-menubar-btn svelte-vzwh12", null, classes_7, { "pcr-menubar-btn-active": $$props.shared.outputPanelOpen });
styles_8 = set_style(span_8, "", styles_8, { color: $$props.shared.outputPanelOpen ? "#4fc3f7" : "" });
});
append($$anchor2, fragment_1);
};
@@ -504,17 +518,17 @@ function Menubar($$anchor, $$props) {
{
var consequent_3 = ($$anchor2) => {
var fragment_2 = root_4();
var span_8 = first_child(fragment_2);
html(span_8, () => '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h7v2H5v5H3V3zm11 0h7v7h-2V5h-5V3zM3 14h2v5h5v2H3v-7zm18 0v7h-7v-2h5v-5h2z"/></svg>', true);
action(span_8, ($$node, $$action_arg) => actionClick == null ? void 0 : actionClick($$node, $$action_arg), () => "maximize");
var span_9 = sibling(span_8, 2);
let classes_7;
let styles_8;
html(span_9, () => '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M6.83 4H20a2 2 0 0 1 2 2v12c0 .34-.09.66-.23.94L20 17.17V8h-9.17l-4-4zm13.66 19.31L17.17 20H4a2 2 0 0 1-2-2V6c0-.34.08-.66.23-.94L.69 3.51L2.1 2.1l19.8 19.8l-1.41 1.41zM15.17 18l-10-10H4v10h11.17z"/></svg>', true);
action(span_9, ($$node, $$action_arg) => actionClick == null ? void 0 : actionClick($$node, $$action_arg), () => "collapse");
var span_9 = first_child(fragment_2);
html(span_9, () => '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h7v2H5v5H3V3zm11 0h7v7h-2V5h-5V3zM3 14h2v5h5v2H3v-7zm18 0v7h-7v-2h5v-5h2z"/></svg>', true);
action(span_9, ($$node, $$action_arg) => actionClick == null ? void 0 : actionClick($$node, $$action_arg), () => "maximize");
var span_10 = sibling(span_9, 2);
let classes_8;
let styles_9;
html(span_10, () => '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M6.83 4H20a2 2 0 0 1 2 2v12c0 .34-.09.66-.23.94L20 17.17V8h-9.17l-4-4zm13.66 19.31L17.17 20H4a2 2 0 0 1-2-2V6c0-.34.08-.66.23-.94L.69 3.51L2.1 2.1l19.8 19.8l-1.41 1.41zM15.17 18l-10-10H4v10h11.17z"/></svg>', true);
action(span_10, ($$node, $$action_arg) => actionClick == null ? void 0 : actionClick($$node, $$action_arg), () => "collapse");
template_effect(() => {
classes_7 = set_class(span_9, 1, "pcr-menubar-btn svelte-vzwh12", null, classes_7, { "pcr-menubar-btn-active": $$props.shared.collapsed });
styles_8 = set_style(span_9, "", styles_8, { color: $$props.shared.collapsed ? "#5e79ff" : "" });
classes_8 = set_class(span_10, 1, "pcr-menubar-btn svelte-vzwh12", null, classes_8, { "pcr-menubar-btn-active": $$props.shared.collapsed });
styles_9 = set_style(span_10, "", styles_9, { color: $$props.shared.collapsed ? "#5e79ff" : "" });
});
append($$anchor2, fragment_2);
};
@@ -592,4 +606,4 @@ export {
Menubar as M,
SearchableList as S
};
//# sourceMappingURL=Menubar-DI-z1NzN.js.map
//# sourceMappingURL=Menubar-DC12j8gE.js.map
+258 -1
View File
@@ -1318,6 +1318,114 @@
}
.pcr-rp-check.svelte-1elgmyq { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #b6b6be; }
.pcr-ext-overlay.svelte-5qj8hq {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center; z-index: 10011;
}
.pcr-ext-modal.svelte-5qj8hq {
background: #1e1e1e; border: 1px solid #444; border-radius: 8px;
padding: 20px 24px; min-width: 380px; max-width: 480px;
max-height: 88vh; overflow-y: auto;
}
.pcr-ext-title.svelte-5qj8hq { font-size: 15px; font-weight: 600; color: #eee; margin-bottom: 4px; }
.pcr-ext-sub.svelte-5qj8hq { font-size: 11px; color: #888; margin-bottom: 14px; line-height: 1.4; }
.pcr-ext-frame.svelte-5qj8hq { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.pcr-ext-frame.svelte-5qj8hq img:where(.svelte-5qj8hq) { width: 96px; height: 96px; object-fit: cover; border-radius: 4px; border: 1px solid #333; }
.pcr-ext-frame.svelte-5qj8hq span:where(.svelte-5qj8hq) { font-size: 11px; color: #777; }
.pcr-ext-label.svelte-5qj8hq { display: block; font-size: 11px; color: #aaa; margin-bottom: 5px; }
.pcr-ext-recipe.svelte-5qj8hq { display: flex; gap: 8px; margin-bottom: 14px; }
.pcr-ext-recipe-btn.svelte-5qj8hq {
flex: 1; display: flex; flex-direction: column; gap: 2px; align-items: flex-start;
padding: 8px 10px; background: #161616; border: 1px solid #444; border-radius: 4px;
color: #ddd; cursor: pointer; text-align: left; transition: border-color 0.15s, background 0.15s;
}
.pcr-ext-recipe-btn.svelte-5qj8hq:hover:not(:disabled) { border-color: #666; }
.pcr-ext-recipe-btn.active.svelte-5qj8hq { border-color: #4fc3f7; background: #15242b; }
.pcr-ext-recipe-btn.unavail.svelte-5qj8hq { opacity: 0.5; cursor: not-allowed; }
.pcr-ext-recipe-btn.svelte-5qj8hq:disabled { cursor: default; }
.pcr-ext-recipe-name.svelte-5qj8hq { font-size: 12px; font-weight: 600; }
.pcr-ext-recipe-sub.svelte-5qj8hq { font-size: 10px; color: #888; }
.pcr-ext-rec-dot.svelte-5qj8hq { color: #4caf50; font-size: 9px; vertical-align: middle; }
.pcr-ext-prompt.svelte-5qj8hq {
width: 100%; box-sizing: border-box; min-height: 70px; resize: vertical;
background: #161616; border: 1px solid #444; border-radius: 4px; color: #ddd;
font-size: 12px; padding: 8px; font-family: inherit;
}
.pcr-ext-prompt.svelte-5qj8hq:focus { outline: none; border-color: #4fc3f7; }
.pcr-ext-prompt-editor.svelte-5qj8hq { padding: 0; overflow: hidden; min-height: 84px; resize: none; }
.pcr-ext-stitch.svelte-5qj8hq { display: flex; align-items: center; gap: 7px; font-size: 11px; color: #aaa; margin-top: 10px; cursor: pointer; }
.pcr-ext-adv-toggle.svelte-5qj8hq {
margin-top: 12px; background: none; border: none; color: #8bd5f5; font-size: 11px;
cursor: pointer; padding: 2px 0;
}
.pcr-ext-adv.svelte-5qj8hq { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; }
.pcr-ext-row.svelte-5qj8hq { display: flex; flex-direction: column; gap: 4px; }
.pcr-ext-row-label.svelte-5qj8hq { font-size: 11px; color: #aaa; display: flex; justify-content: space-between; }
.pcr-ext-row-label.svelte-5qj8hq small:where(.svelte-5qj8hq) { color: #6cc; }
.pcr-ext-grid2.svelte-5qj8hq { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.pcr-ext-field.svelte-5qj8hq { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #aaa; }
.pcr-ext-field.svelte-5qj8hq select:where(.svelte-5qj8hq), .pcr-ext-field.svelte-5qj8hq input[type="number"]:where(.svelte-5qj8hq) {
background: #161616; border: 1px solid #444; border-radius: 4px; color: #ddd;
font-size: 12px; padding: 5px 6px; font-family: inherit;
}
.pcr-ext-field-check.svelte-5qj8hq { flex-direction: row; align-items: center; gap: 6px; align-self: end; padding-bottom: 6px; }
.pcr-ext-progress.svelte-5qj8hq { margin-top: 12px; }
.pcr-ext-bar.svelte-5qj8hq { height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
.pcr-ext-fill.svelte-5qj8hq { height: 100%; background: #4fc3f7; border-radius: 3px; transition: width 0.3s; }
.pcr-ext-phase.svelte-5qj8hq { font-size: 11px; color: #aaa; margin-top: 6px; text-transform: capitalize; }
.pcr-ext-preview.svelte-5qj8hq, .pcr-ext-result.svelte-5qj8hq { width: 100%; margin-top: 8px; border-radius: 4px; max-height: 240px; }
.pcr-ext-err.svelte-5qj8hq { font-size: 12px; color: #f44336; }
.pcr-ext-done.svelte-5qj8hq { font-size: 12px; color: #4caf50; margin-bottom: 6px; }
.pcr-ext-actions.svelte-5qj8hq { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.pcr-ext-btn.svelte-5qj8hq {
padding: 6px 16px; font-size: 12px; border: 1px solid #555; border-radius: 4px;
background: transparent; color: #ccc; cursor: pointer;
}
.pcr-ext-btn.svelte-5qj8hq:hover { background: rgba(255,255,255,0.08); }
.pcr-ext-btn.svelte-5qj8hq:disabled { opacity: 0.5; cursor: default; }
.pcr-ext-primary.svelte-5qj8hq { background: #4fc3f7; color: #111; border-color: #4fc3f7; }
.pcr-ext-primary.svelte-5qj8hq:hover { background: #39b0e4; }
.pcr-ext-primary.svelte-5qj8hq:disabled { background: #2a7a9e; }
.pcr-sm-overlay.svelte-1j60x3s {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center; z-index: 10011;
}
.pcr-sm-modal.svelte-1j60x3s { background: #1e1e1e; border: 1px solid #444; border-radius: 8px; padding: 20px 24px; min-width: 360px; max-width: 460px; max-height: 88vh; overflow-y: auto; }
.pcr-sm-title.svelte-1j60x3s { font-size: 15px; font-weight: 600; color: #eee; margin-bottom: 4px; }
.pcr-sm-sub.svelte-1j60x3s { font-size: 11px; color: #888; margin-bottom: 14px; line-height: 1.4; }
.pcr-sm-label.svelte-1j60x3s { font-size: 11px; color: #aaa; margin-bottom: 6px; }
.pcr-sm-mults.svelte-1j60x3s { display: flex; gap: 8px; }
.pcr-sm-mult.svelte-1j60x3s {
flex: 1; padding: 8px 0; border: 1px solid #555; border-radius: 4px; background: transparent;
color: #ccc; font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.pcr-sm-mult.svelte-1j60x3s:hover { border-color: #888; color: #fff; }
.pcr-sm-mult.pcr-sm-active.svelte-1j60x3s { border-color: #4fc3f7; color: #4fc3f7; background: rgba(79,195,247,0.1); }
.pcr-sm-adv-toggle.svelte-1j60x3s { margin-top: 12px; background: none; border: none; color: #8bd5f5; font-size: 11px; cursor: pointer; padding: 2px 0; }
.pcr-sm-adv.svelte-1j60x3s { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; }
.pcr-sm-field.svelte-1j60x3s { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #aaa; }
.pcr-sm-field.svelte-1j60x3s small:where(.svelte-1j60x3s), .pcr-sm-check.svelte-1j60x3s small:where(.svelte-1j60x3s) { color: #777; }
.pcr-sm-field.svelte-1j60x3s select:where(.svelte-1j60x3s) {
background: #161616; border: 1px solid #444; border-radius: 4px; color: #ddd;
font-size: 12px; padding: 5px 6px; font-family: inherit;
}
.pcr-sm-check.svelte-1j60x3s { display: flex; align-items: center; gap: 7px; font-size: 11px; color: #aaa; cursor: pointer; }
.pcr-sm-progress.svelte-1j60x3s { margin-top: 14px; }
.pcr-sm-bar.svelte-1j60x3s { height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
.pcr-sm-fill.svelte-1j60x3s { height: 100%; background: #4fc3f7; border-radius: 3px; transition: width 0.3s; }
.pcr-sm-phase.svelte-1j60x3s { font-size: 11px; color: #aaa; margin-top: 6px; text-transform: capitalize; }
.pcr-sm-preview.svelte-1j60x3s, .pcr-sm-result.svelte-1j60x3s { width: 100%; margin-top: 8px; border-radius: 4px; max-height: 240px; }
.pcr-sm-err.svelte-1j60x3s { font-size: 12px; color: #f44336; }
.pcr-sm-done.svelte-1j60x3s { font-size: 12px; color: #4caf50; margin-bottom: 6px; }
.pcr-sm-actions.svelte-1j60x3s { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.pcr-sm-btn.svelte-1j60x3s { padding: 6px 16px; font-size: 12px; border: 1px solid #555; border-radius: 4px; background: transparent; color: #ccc; cursor: pointer; }
.pcr-sm-btn.svelte-1j60x3s:hover { background: rgba(255,255,255,0.08); }
.pcr-sm-btn.svelte-1j60x3s:disabled { opacity: 0.5; cursor: default; }
.pcr-sm-primary.svelte-1j60x3s { background: #4fc3f7; color: #111; border-color: #4fc3f7; }
.pcr-sm-primary.svelte-1j60x3s:hover { background: #39b0e4; }
.pcr-sm-primary.svelte-1j60x3s:disabled { background: #2a7a9e; }
.pcr-ctx.svelte-esmc9y {
/* Above the viewer overlay (.pcr-viewer is z-index 10001 with an opaque
background) — portal moves this menu to <body> as the overlay's sibling,
@@ -1424,6 +1532,28 @@
}
/* centered over the thumb so it never collides with the corner badges (dot,
layers, fork pip / expander, opbadge) — the .selected ring already frames it */
/* pin marker — top-left; shares the corner with the layers badge, which shifts right when both show */
.pcr-lin-pinbadge.svelte-5cciiw {
position: absolute;
top: 3px;
left: 3px;
width: 15px;
height: 15px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
line-height: 1;
border-radius: 4px;
background: rgba(20, 20, 20, 0.82);
color: #ffd24a;
box-shadow: 0 0 0 1px rgba(255, 200, 60, 0.6);
pointer-events: none;
z-index: 3;
}
.pcr-viewer-lineage-node.pinned.svelte-5cciiw .pcr-viewer-lineage-layers:where(.svelte-5cciiw) {
left: 21px;
}
.pcr-lin-selcheck.svelte-5cciiw {
position: absolute;
top: 50%;
@@ -1970,6 +2100,7 @@
flex: 1;
}
.pcr-viewer-history-thumb.svelte-5cciiw {
position: relative;
flex-shrink: 0;
width: 40px;
height: 40px;
@@ -2064,6 +2195,7 @@
.pcr-viewer-history-grid.svelte-5cciiw::-webkit-scrollbar-thumb:hover { background: #555; }
.pcr-viewer-grid-thumb.svelte-5cciiw {
position: relative;
width: 56.5px;
height: 56.5px;
border: 2px solid transparent;
@@ -2077,6 +2209,27 @@
.pcr-viewer-grid-thumb.svelte-5cciiw:hover:not(.current) { border-color: #666; transform: scale(1.05); z-index: 1; }
.pcr-viewer-grid-thumb.svelte-5cciiw img:where(.svelte-5cciiw) { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
/* ▶ overlay marking a poster-framed thumbnail as a playable video clip */
.pcr-viewer-vidbadge.svelte-5cciiw {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding-left: 1px;
font-size: 9px;
line-height: 1;
color: #fff;
background: rgba(0, 0, 0, 0.55);
border-radius: 50%;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
/* metadata content */
.pcr-viewer-meta-content.svelte-5cciiw {
flex: 1;
@@ -3650,7 +3803,8 @@
overflow: hidden;
background: var(--pcr-fs-editor-surface, transparent);
}
.pcr-image-container.svelte-ro1n43 img:where(.svelte-ro1n43) {
.pcr-image-container.svelte-ro1n43 img:where(.svelte-ro1n43),
.pcr-image-container.svelte-ro1n43 video:where(.svelte-ro1n43) {
width: 100%;
height: 100%;
object-fit: contain;
@@ -3811,6 +3965,84 @@
background: rgba(255, 255, 255, 0.3);
}
.pcr-region-panel.svelte-wos87s {
display: none;
flex-direction: column;
background: rgba(0, 0, 0, 0.4);
min-width: 240px;
flex-shrink: 0;
overflow: hidden;
position: relative;
}
.lg-node-widgets .pcr-region-panel.svelte-wos87s {
background: rgb(0 0 0 / 46%);
}
.pcr-region-panel-close-btn.svelte-wos87s {
position: absolute;
top: 4px;
right: 4px;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
color: #fff;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.9)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.7));
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s;
}
.pcr-region-panel-close-btn.pcr-close-dim.svelte-wos87s {
opacity: 0.3;
pointer-events: auto;
}
.pcr-region-panel-close-btn.pcr-close-show.svelte-wos87s {
opacity: 1;
pointer-events: auto;
}
.pcr-region-panel-body.svelte-wos87s {
flex: 1;
min-height: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--pcr-fs-editor-surface, transparent);
}
.pcr-region-panel-placeholder.svelte-wos87s {
color: rgba(255, 255, 255, 0.3);
font-size: 12px;
font-style: italic;
text-align: center;
padding: 0 12px;
user-select: none;
}
.pcr-region-divider.svelte-wos87s {
width: 4px;
background: #242424;
cursor: col-resize;
flex-shrink: 0;
display: none;
align-items: center;
justify-content: center;
}
.pcr-region-divider.svelte-wos87s::before {
content: '';
width: 2px;
height: 40px;
background: rgba(255, 255, 255, 0.15);
border-radius: 1px;
transition: background 0.15s;
}
.pcr-region-divider.svelte-wos87s:hover::before {
background: rgba(255, 255, 255, 0.3);
}
.pcr-footer.svelte-172i5st {
display: flex;
align-items: center;
@@ -6607,9 +6839,18 @@
color: #5dcaff;
min-width: 90px;
}
/* custom (non-preset) resolution: dim the dropdown, light up the W×H boxes */
.pcr-resolution-preset-dimmed.svelte-82atyh {
opacity: 0.45;
}
.pcr-resolution-input-dimmed.svelte-82atyh {
opacity: 0.4;
}
.pcr-resolution-input-active.svelte-82atyh {
border-color: #5dcaff;
color: #fff;
background: #222b33;
}
.pcr-resolution-preset-menu.svelte-82atyh {
position: fixed;
background: rgba(30, 30, 30, 0.98);
@@ -7562,6 +7803,14 @@
.pcr-precision-installed.svelte-ycwcax:hover {
background: rgba(79, 195, 247, 0.25);
}
.pcr-precision-partial.svelte-ycwcax {
background: rgba(255, 183, 77, 0.15);
color: #ffb74d;
border: 1px solid rgba(255, 183, 77, 0.4);
}
.pcr-precision-partial.svelte-ycwcax:hover {
background: rgba(255, 183, 77, 0.28);
}
.pcr-precision-missing.svelte-ycwcax {
background: rgba(255, 255, 255, 0.05);
color: #888;
@@ -7571,6 +7820,14 @@
color: #ccc;
border-color: #666;
}
/* a started-but-unfinished download gets an amber edge so it reads as
"resume", not "ready" */
.pcr-picker-catalog-partial.svelte-ycwcax {
border-left-color: #ffb74d;
}
.pcr-picker-resume-hint.svelte-ycwcax {
color: #ffb74d;
}
.pcr-picker-civitai-loading.svelte-ycwcax {
padding: 10px 12px;
@@ -1,15 +1,21 @@
const PRIMARY_FOLDERS = /* @__PURE__ */ new Set(["diffusion_models", "unet", "checkpoints"]);
function precisionTier(precision) {
return /fp16|bf16|fp32/i.test(precision || "") ? "high" : "low";
}
function extractPrecisions(files) {
const precisions = /* @__PURE__ */ new Set();
for (const f of files) if (f.variants) for (const v of f.variants) precisions.add(v.precision);
return [...precisions];
const primary = files.find((f) => f.variants && PRIMARY_FOLDERS.has(f.folder)) || files.find((f) => f.variants);
const precisions = [];
for (const v of (primary == null ? void 0 : primary.variants) || []) {
if (!precisions.includes(v.precision)) precisions.push(v.precision);
}
return precisions;
}
function resolveFilesForPrecision(files, precision) {
const tier = precisionTier(precision);
return files.map((f) => {
if (f.variants) {
const match = f.variants.find((v) => v.precision === precision);
return match ? { label: f.label, folder: f.folder, filename: match.filename, size_bytes: match.size_bytes, source: match.source } : null;
}
return f;
if (!f.variants) return f;
const match = f.variants.find((v) => v.precision === precision) || f.variants.find((v) => precisionTier(v.precision) === tier) || f.variants[0];
return match ? { label: f.label, folder: f.folder, filename: match.filename, size_bytes: match.size_bytes, source: match.source } : null;
}).filter(Boolean);
}
const ARCHITECTURES = [
@@ -66,9 +72,12 @@ const FAMILIES = {
{ id: "qwen_aio", label: "Qwen AIO" }
],
wan22: [
{ id: "wan22_t2v", label: "Wan T2V 14B" },
{ id: "wan22_i2v", label: "Wan I2V 14B" },
{ id: "wan22_5b", label: "Wan 5B" }
{ id: "wan22_i2v_gguf", label: "Wan I2V 14B (GGUF)" },
{ id: "wan22_t2v", label: "Wan T2V 14B" },
{ id: "wan22_t2v_gguf", label: "Wan T2V 14B (GGUF)" },
{ id: "wan22_5b", label: "Wan TI2V 5B" },
{ id: "wan22_5b_gguf", label: "Wan TI2V 5B (GGUF)" }
],
ltx: [
{ id: "ltx23", label: "LTX 2.3" }
@@ -93,4 +102,4 @@ export {
extractPrecisions as e,
resolveFilesForPrecision as r
};
//# sourceMappingURL=model-constants-DOP7d958.js.map
//# sourceMappingURL=model-constants-cVxhUf51.js.map
+1 -1
View File
@@ -9,7 +9,7 @@ import { d as delegate, p as push, a as prop, i as if_block, f as sibling, t as
import { a as onMount, o as onDestroy } from "./index-client-iMRCrpBY.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { b as bind_value } from "./input-DFQhebEz.js";
import { M as Menubar, S as SearchableList } from "./Menubar-DI-z1NzN.js";
import { M as Menubar, S as SearchableList } from "./Menubar-DC12j8gE.js";
var root_1$4 = from_html(`<img class="pcr-fs-topbar-logo svelte-93wsqh" alt="PromptChain"/>`);
var root$5 = from_html(`<div class="pcr-fs-topbar svelte-93wsqh"><div class="pcr-fs-topbar-left svelte-93wsqh"><!> <span class="pcr-fs-topbar-title svelte-93wsqh"></span></div> <div class="pcr-fs-topbar-right svelte-93wsqh"><div class="pcr-fs-topbar-actions svelte-93wsqh"><input type="number" class="pcr-fs-batch-input svelte-93wsqh" min="1" max="99" title="Batch count"/> <span class="pcr-fs-run-btn svelte-93wsqh" title="Queue prompt (Ctrl+Enter)"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"></path></svg> <span>Run</span></span> <span class="pcr-fs-cancel-btn pcr-fs-inactive svelte-93wsqh" title="Cancel execution"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></svg></span> <span class="pcr-fs-queue-badge pcr-fs-inactive svelte-93wsqh">0 active</span></div> <div class="pcr-fs-panel-toggles svelte-93wsqh"><span class="pcr-fs-header-btn svelte-93wsqh" title="Toggle 3D Poser panel" data-fs-action="pose"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.5 6c-2.61.7-5.67 1-8.5 1s-5.89-.3-8.5-1L3 8c1.86.5 4 .83 6 1v13h2v-6h2v6h2V9c2-.17 4.14-.5 6-1l-.5-2zM12 6c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"></path></svg></span> <span class="pcr-fs-header-btn svelte-93wsqh" title="Toggle output panel" data-fs-action="output"><svg width="16" height="16" viewBox="0 -960 960 960" fill="currentColor"><path d="M400-280h160v-80H400v80Zm0-160h280v-80H400v80ZM280-600h400v-80H280v80Zm200 120ZM80-80v-80h102q-48-23-77.5-68T75-330q0-79 55.5-134.5T265-520v80q-45 0-77.5 32T155-330q0 39 24 69t61 38v-97h80v240H80Zm320-40v-80h360v-560H200v160h-80v-160q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H400Z"></path></svg></span> <span class="pcr-fs-header-btn svelte-93wsqh" title="Toggle image preview" data-fs-action="image"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg></span></div> <span class="pcr-fs-close-btn svelte-93wsqh" title="Close (Escape)"></span></div></div>`);
function Topbar($$anchor, $$props) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { G as GeneratedGallery } from "./GeneratedGallery-Dw0CC3-N.js";
import { G as GeneratedGallery } from "./GeneratedGallery-D5XbkMk7.js";
function mountGallery(target, props) {
return mount(GeneratedGallery, { target, props });
}
+404 -304
View File
@@ -1,7 +1,7 @@
import { p as push, a as prop, D as comment, v as first_child, i as if_block, g as get, k as append, l as pop, s as state, e as set, f as sibling, t as template_effect, r as event, A as from_html, n as child, y as set_text, x as set_attribute, d as delegate, c as proxy, u as user_effect, E as untrack, o as bind_this, j as delegated, C as tick, m as user_derived, w as each, z as index, h as set_class, F as update, M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { b as bind_value } from "./input-DFQhebEz.js";
import { e as extractPrecisions, r as resolveFilesForPrecision } from "./model-constants-DOP7d958.js";
import { e as extractPrecisions, r as resolveFilesForPrecision } from "./model-constants-cVxhUf51.js";
import { api } from "/scripts/api.js";
import { s as safeJson, H as HttpError } from "./api-context-BFKo1mCD.js";
var root_2$4 = from_html(`<div class="pcr-hover-card-version svelte-oulz7g"> </div>`);
@@ -104,31 +104,34 @@ var root_1$3 = from_html(`<div class="pcr-model-panel-empty">Loading...</div>`);
var root_3$3 = from_html(`<div class="pcr-picker-recognition svelte-ycwcax"> </div>`);
var root_5$3 = from_html(`<span class="pcr-model-version-badge svelte-ycwcax"> </span>`);
var root_6$3 = from_html(`<span class="pcr-picker-item-meta svelte-ycwcax"> </span>`);
var root_4$2 = from_html(`<div><div class="pcr-picker-item-info svelte-ycwcax"><span class="pcr-picker-item-name svelte-ycwcax"> <!></span> <!></div> <span class="pcr-picker-item-arrow svelte-ycwcax">▸</span></div>`);
var root_8$2 = from_html(`<span> </span>`);
var root_7$2 = from_html(`<div class="pcr-picker-item pcr-picker-catalog-item svelte-ycwcax"><div class="pcr-picker-item-info svelte-ycwcax"><span class="pcr-picker-item-name svelte-ycwcax"> </span> <div class="pcr-picker-precision-row svelte-ycwcax"></div></div> <span class="pcr-picker-item-arrow svelte-ycwcax">›</span></div>`);
var root_11$1 = from_html(`<span class="pcr-model-version-badge svelte-ycwcax"> </span>`);
var root_12 = from_html(`<span class="pcr-picker-item-meta svelte-ycwcax"> </span>`);
var root_10$2 = from_html(`<div><div class="pcr-picker-item-info svelte-ycwcax"><span class="pcr-picker-item-name svelte-ycwcax"> <!></span> <!></div> <span class="pcr-picker-item-arrow svelte-ycwcax"> </span></div>`);
var root_9$2 = from_html(`<div class="pcr-picker-civitai-header svelte-ycwcax">AVAILABLE FOR DOWNLOAD</div> <!>`, 1);
var root_14 = from_html(`<div class="pcr-picker-item pcr-picker-civitai-item svelte-ycwcax"><div class="pcr-picker-item-info svelte-ycwcax"><span class="pcr-picker-item-name svelte-ycwcax"> </span> <span class="pcr-picker-item-meta svelte-ycwcax"> </span></div> <span class="pcr-picker-item-arrow svelte-ycwcax">↓</span></div>`);
var root_15 = from_html(`<div class="pcr-picker-civitai-loading svelte-ycwcax">Searching CivitAI…</div>`);
var root_16 = from_html(`<div class="pcr-picker-civitai-loading svelte-ycwcax">Loading more…</div>`);
var root_17 = from_html(`<div class="pcr-picker-civitai-empty svelte-ycwcax">No results on CivitAI</div>`);
var root_13 = from_html(`<div class="pcr-picker-civitai-header svelte-ycwcax">CIVITAI</div> <div class="pcr-picker-civitai-section svelte-ycwcax"><!> <!></div>`, 1);
var root_18 = from_html(`<div class="pcr-model-panel-empty">No matches</div>`);
var root_7$2 = from_html(`<div class="pcr-picker-precision-row svelte-ycwcax"></div>`);
var root_4$2 = from_html(`<div><div class="pcr-picker-item-info svelte-ycwcax"><span class="pcr-picker-item-name svelte-ycwcax"> <!></span> <!> <!></div> <span class="pcr-picker-item-arrow svelte-ycwcax">▸</span></div>`);
var root_10$2 = from_html(`<span> </span>`);
var root_9$2 = from_html(`<div><div class="pcr-picker-item-info svelte-ycwcax"><span class="pcr-picker-item-name svelte-ycwcax"> </span> <div class="pcr-picker-precision-row svelte-ycwcax"></div></div> <span class="pcr-picker-item-arrow svelte-ycwcax">›</span></div>`);
var root_13 = from_html(`<span class="pcr-model-version-badge svelte-ycwcax"> </span>`);
var root_15 = from_html(` <span class="pcr-picker-resume-hint svelte-ycwcax">resume ↻</span>`, 1);
var root_14 = from_html(`<span class="pcr-picker-item-meta svelte-ycwcax"> <!></span>`);
var root_12 = from_html(`<div><div class="pcr-picker-item-info svelte-ycwcax"><span class="pcr-picker-item-name svelte-ycwcax"> <!></span> <!></div> <span class="pcr-picker-item-arrow svelte-ycwcax"> </span></div>`);
var root_11$1 = from_html(`<div class="pcr-picker-civitai-header svelte-ycwcax">AVAILABLE FOR DOWNLOAD</div> <!>`, 1);
var root_17 = from_html(`<div class="pcr-picker-item pcr-picker-civitai-item svelte-ycwcax"><div class="pcr-picker-item-info svelte-ycwcax"><span class="pcr-picker-item-name svelte-ycwcax"> </span> <span class="pcr-picker-item-meta svelte-ycwcax"> </span></div> <span class="pcr-picker-item-arrow svelte-ycwcax">↓</span></div>`);
var root_18 = from_html(`<div class="pcr-picker-civitai-loading svelte-ycwcax">Searching CivitAI…</div>`);
var root_19 = from_html(`<div class="pcr-picker-civitai-loading svelte-ycwcax">Loading more…</div>`);
var root_20 = from_html(`<div class="pcr-picker-civitai-empty svelte-ycwcax">No results on CivitAI</div>`);
var root_16 = from_html(`<div class="pcr-picker-civitai-header svelte-ycwcax">CIVITAI</div> <div class="pcr-picker-civitai-section svelte-ycwcax"><!> <!></div>`, 1);
var root_21 = from_html(`<div class="pcr-model-panel-empty">No matches</div>`);
var root_2$3 = from_html(`<div class="pcr-picker-header svelte-ycwcax"><input class="pcr-picker-search svelte-ycwcax" type="text" placeholder="Search models..."/> <!></div> <div><!> <!> <!> <!> <!></div>`, 1);
var root_20 = from_html(`<div><span> </span> <span class="pcr-picker-version-arrow svelte-ycwcax">▶</span></div>`);
var root_19 = from_html(`<div class="pcr-picker-submenu svelte-ycwcax"><div class="pcr-picker-submenu-header svelte-ycwcax">SELECT VERSION</div> <!></div>`);
var root_22 = from_html(`<div class="pcr-picker-version-item svelte-ycwcax"><span> </span> <span class="pcr-picker-version-arrow svelte-ycwcax">↓</span></div>`);
var root_21 = from_html(`<div class="pcr-picker-submenu svelte-ycwcax"><div class="pcr-picker-submenu-header svelte-ycwcax">SELECT VERSION</div> <!></div>`);
var root_25 = from_html(`<div class="pcr-picker-category-header svelte-ycwcax">TEMPLATES</div>`);
var root_24 = from_html(`<div class="pcr-picker-submenu-item pcr-picker-current-workflow svelte-ycwcax" title="Swap this checkpoint into your current graph and reapply its settings — keeps your wiring (regional couple, etc.)">(Current Workflow)</div> <!>`, 1);
var root_29 = from_html(`<div class="pcr-picker-category-header svelte-ycwcax"> </div>`);
var root_30 = from_html(`<div class="pcr-picker-submenu-item svelte-ycwcax"> </div>`);
var root_28 = from_html(`<!> <!>`, 1);
var root_31 = from_html(`<div class="pcr-picker-submenu-item pcr-picker-submenu-hint svelte-ycwcax">No templates available</div>`);
var root_23 = from_html(`<div class="pcr-picker-submenu svelte-ycwcax"><div class="pcr-picker-submenu-header svelte-ycwcax"> </div> <!> <!></div>`);
var root_23 = from_html(`<div><span> </span> <span class="pcr-picker-version-arrow svelte-ycwcax">▶</span></div>`);
var root_22 = from_html(`<div class="pcr-picker-submenu svelte-ycwcax"><div class="pcr-picker-submenu-header svelte-ycwcax">SELECT VERSION</div> <!></div>`);
var root_25 = from_html(`<div class="pcr-picker-version-item svelte-ycwcax"><span> </span> <span class="pcr-picker-version-arrow svelte-ycwcax">↓</span></div>`);
var root_24 = from_html(`<div class="pcr-picker-submenu svelte-ycwcax"><div class="pcr-picker-submenu-header svelte-ycwcax">SELECT VERSION</div> <!></div>`);
var root_28 = from_html(`<div class="pcr-picker-category-header svelte-ycwcax">TEMPLATES</div>`);
var root_27 = from_html(`<div class="pcr-picker-submenu-item pcr-picker-current-workflow svelte-ycwcax" title="Swap this checkpoint into your current graph and reapply its settings — keeps your wiring (regional couple, etc.)">(Current Workflow)</div> <!>`, 1);
var root_32 = from_html(`<div class="pcr-picker-category-header svelte-ycwcax"> </div>`);
var root_33 = from_html(`<div class="pcr-picker-submenu-item svelte-ycwcax"> </div>`);
var root_31 = from_html(`<!> <!>`, 1);
var root_34 = from_html(`<div class="pcr-picker-submenu-item pcr-picker-submenu-hint svelte-ycwcax">No templates available</div>`);
var root_26 = from_html(`<div class="pcr-picker-submenu svelte-ycwcax"><div class="pcr-picker-submenu-header svelte-ycwcax"> </div> <!> <!></div>`);
var root$3 = from_html(`<div class="pcr-model-picker svelte-ycwcax"><!></div> <!> <!> <!> <!>`, 1);
function ModelPicker($$anchor, $$props) {
push($$props, true);
@@ -177,7 +180,11 @@ function ModelPicker($$anchor, $$props) {
let listEl;
let searchEl;
let panelStyle = state("");
let catalogPrecisionStatus = state(proxy({}));
let catalogPrecisionStatus = user_derived(() => {
const out = {};
for (const c of get(catalog)) out[c.hash] = c.precision_status || {};
return out;
});
async function loadData() {
try {
const [modelsRes, templatesRes, catalogRes] = await Promise.all([
@@ -198,7 +205,6 @@ function ModelPicker($$anchor, $$props) {
}).then((r) => r.json());
set(modelSettings, settingsRes.settings || {}, true);
}
await checkCatalogPrecisions();
set(loading, false);
requestAnimationFrame(() => searchEl == null ? void 0 : searchEl.focus());
focusDownloadedModel();
@@ -234,43 +240,6 @@ function ModelPicker($$anchor, $$props) {
await tick();
(_b = listEl == null ? void 0 : listEl.querySelector(".pcr-picker-item-highlight")) == null ? void 0 : _b.scrollIntoView({ block: "center" });
}
async function checkCatalogPrecisions() {
if (!get(catalog).length) return;
const filesToCheck = [];
const fileMap = [];
const status = {};
for (const entry of get(catalog)) {
status[entry.hash] = {};
for (const f of entry.files || []) {
if (!f.variants) continue;
for (const v of f.variants) {
if (f.folder === "diffusion_models" || f.folder === "unet") {
filesToCheck.push({ filename: v.filename, folder: f.folder });
fileMap.push({ entryHash: entry.hash, precision: v.precision });
}
}
}
}
if (!filesToCheck.length) {
set(catalogPrecisionStatus, status, true);
return;
}
try {
const checkRes = await fetch("/promptchain/models/check-files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ files: filesToCheck })
});
const checkData = await checkRes.json();
for (let i = 0; i < (checkData.results || []).length; i++) {
if (checkData.results[i].exists) {
status[fileMap[i].entryHash][fileMap[i].precision] = true;
}
}
} catch {
}
set(catalogPrecisionStatus, status, true);
}
let groupedModels = user_derived(() => {
const sorted = get(models).slice().sort((a, b) => {
var _a, _b;
@@ -327,7 +296,7 @@ function ModelPicker($$anchor, $$props) {
const terms = q ? q.split(/\s+/) : [];
return get(catalog).filter((c) => {
const status = get(catalogPrecisionStatus)[c.hash] || {};
if (!Object.values(status).some((v) => v)) return false;
if (!Object.values(status).some((v) => v === "installed" || v === "partial")) return false;
if (!terms.length) return true;
const text = [
c.display_name,
@@ -343,7 +312,7 @@ function ModelPicker($$anchor, $$props) {
const q = get(searchQuery).toLowerCase().trim();
let items = get(catalog).filter((c) => {
const status = get(catalogPrecisionStatus)[c.hash] || {};
return !Object.values(status).some((v) => v);
return !Object.values(status).some((v) => v === "installed" || v === "partial");
});
if (q) {
const terms = q.split(/\s+/);
@@ -363,10 +332,13 @@ function ModelPicker($$anchor, $$props) {
name: entry.model_name || entry.display_name || "",
architecture: entry.architecture || "",
family: entry.family || "",
partial: false,
versions: []
});
}
groups.get(key).versions.push(entry);
const g = groups.get(key);
g.versions.push(entry);
if (entry.partial) g.partial = true;
}
for (const g of groups.values()) {
g.versions.sort((a, b) => (b.version || "").localeCompare(a.version || "", void 0, { numeric: true }));
@@ -484,16 +456,15 @@ function ModelPicker($$anchor, $$props) {
return scope.architecture === arch;
});
}
async function handleCatalogPrecisionClick(entry, prec, installed) {
async function handleCatalogPrecisionClick(entry, prec, state2) {
var _a;
if (!installed) {
$$props.onShowCatalogDownload(entry);
if (state2 !== "installed") {
$$props.onShowCatalogDownload(entry, prec);
return;
}
const resolved = resolveFilesForPrecision(entry.files, prec);
const diffFile = resolved.find((f) => f.folder === "diffusion_models" || f.folder === "unet");
if (!diffFile) return;
const localModel = get(models).find((m) => m.filename === diffFile.filename);
const localModel = diffFile && get(models).find((m) => m.filename === diffFile.filename);
if (localModel && templatesForModel(localModel).length > 0) {
set(
activeSubmenu,
@@ -505,10 +476,68 @@ function ModelPicker($$anchor, $$props) {
true
);
set(activeNestedSubmenu, null);
} else {
$$props.onShowCatalogDownload(entry, prec);
}
}
function primaryFileEntry(settings) {
const files = (settings == null ? void 0 : settings.files) || [];
const isPrimaryFolder = (f) => f.folder === "diffusion_models" || f.folder === "unet" || f.folder === "checkpoints";
return files.find((f) => f.primary && isPrimaryFolder(f)) || files.find(isPrimaryFolder) || null;
}
function groupPrecisions(group) {
var _a, _b;
const settings = get(modelSettings)[(_a = group.versions[0]) == null ? void 0 : _a.hash];
const variants = ((_b = primaryFileEntry(settings)) == null ? void 0 : _b.variants) || [];
if (variants.length < 2) return [];
return variants.map((v) => {
const fnameLower = (v.filename || "").toLowerCase();
const localModel = get(models).find((m) => (m.filename || "").toLowerCase() === fnameLower);
return {
precision: v.precision,
filename: v.filename,
installed: !!localModel,
model: localModel || null
};
});
}
function entryForGroup(group) {
var _a, _b;
const s = get(modelSettings)[(_a = group.versions[0]) == null ? void 0 : _a.hash] || {};
const entry = {
hash: (_b = group.versions[0]) == null ? void 0 : _b.hash,
display_name: s.display_name || group.name,
model_name: s.model_name || group.name,
version: s.version || "",
architecture: s.architecture || group.architecture || "",
family: s.family || group.family || "",
description: s.description || "",
files: s.files || []
};
if (s.civitai_model_id) entry.civitai_model_id = s.civitai_model_id;
if (s.thumbnail) entry.thumbnail = s.thumbnail;
return entry;
}
function handleInstalledPrecisionClick(prec) {
var _a;
if (prec.model && templatesForModel(prec.model).length > 0) {
set(activeNestedSubmenu, null);
set(
activeSubmenu,
{
type: "template",
model: prec.model,
displayName: ((_a = get(modelSettings)[prec.model.hash]) == null ? void 0 : _a.display_name) || prec.model.filename
},
true
);
} else {
$$props.onClose();
}
}
function handleMissingPrecisionClick(group, prec) {
$$props.onShowCatalogDownload(entryForGroup(group), prec.precision);
}
function sortCategories(cats) {
const known = (get(categoryOrder) || []).filter((c) => cats.includes(c));
const unknown = cats.filter((c) => {
@@ -522,6 +551,26 @@ function ModelPicker($$anchor, $$props) {
if (settings == null ? void 0 : settings.version_label) return settings.version_label;
const version = (settings == null ? void 0 : settings.version) || "";
const filename = (model.filename || "").toLowerCase();
const precision = declaredPrecision(settings, filename) || sniffPrecision(filename);
if (precision && version) {
const esc = precision.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (version.toLowerCase().includes(precision.toLowerCase())) {
return version.replace(new RegExp(`\\s*${esc}`, "i"), ` (${precision})`);
}
return `${version} (${precision})`;
}
return version || model.filename || "";
}
function declaredPrecision(settings, filenameLower) {
for (const f of (settings == null ? void 0 : settings.files) || []) {
if ((f.filename || "").toLowerCase() === filenameLower) return f.precision || null;
for (const v of f.variants || []) {
if ((v.filename || "").toLowerCase() === filenameLower) return v.precision || null;
}
}
return null;
}
function sniffPrecision(filenameLower) {
const precisionPatterns = [
[/bf16/i, "BF16"],
[/fp16/i, "FP16"],
@@ -531,20 +580,10 @@ function ModelPicker($$anchor, $$props) {
[/fp4/i, "FP4"],
[/\.gguf$/i, "GGUF"]
];
let precision = null;
for (const [pattern, label] of precisionPatterns) {
if (pattern.test(filename)) {
precision = label;
break;
}
if (pattern.test(filenameLower)) return label;
}
if (precision && version) {
if (version.toLowerCase().includes(precision.toLowerCase())) {
return version.replace(new RegExp(`\\s*${precision}`, "i"), ` (${precision})`);
}
return `${version} (${precision})`;
}
return version || model.filename || "";
return null;
}
function buildCatalogVersionLabel(entry) {
var _a, _b;
@@ -724,6 +763,7 @@ function ModelPicker($$anchor, $$props) {
const model = user_derived(() => get(group).versions[0]);
const displayName = user_derived(() => get(group).name);
const meta = user_derived(() => [get(group).architecture, get(group).family].filter(Boolean).join(" · "));
const precs = user_derived(() => groupPrecisions(get(group)));
var div_5 = root_4$2();
let classes_1;
var div_6 = child(div_5);
@@ -738,7 +778,7 @@ function ModelPicker($$anchor, $$props) {
append($$anchor4, span_1);
};
if_block(node_3, ($$render) => {
if (get(group).versions.length > 1) $$render(consequent_2);
if (get(group).versions.length > 1 && !get(precs).length) $$render(consequent_2);
});
}
var node_4 = sibling(span, 2);
@@ -753,6 +793,40 @@ function ModelPicker($$anchor, $$props) {
if (get(meta)) $$render(consequent_3);
});
}
var node_5 = sibling(node_4, 2);
{
var consequent_4 = ($$anchor4) => {
var div_7 = root_7$2();
each(div_7, 21, () => get(precs), index, ($$anchor5, p) => {
var span_3 = root_8$2();
let classes_2;
var text_5 = child(span_3);
template_effect(
($0, $1) => {
classes_2 = set_class(span_3, 1, "pcr-picker-precision-tag svelte-ycwcax", null, classes_2, {
"pcr-precision-installed": get(p).installed,
"pcr-precision-missing": !get(p).installed
});
set_attribute(span_3, "title", $0);
set_text(text_5, `${$1 ?? ""}${get(p).installed ? "" : " ↓"}`);
},
[
() => get(p).installed ? `${get(p).precision.toUpperCase()} installed — use it` : `Download ${get(p).precision.toUpperCase()}`,
() => get(p).precision.toUpperCase()
]
);
delegated("click", span_3, (e) => {
e.stopPropagation();
get(p).installed ? handleInstalledPrecisionClick(get(p)) : handleMissingPrecisionClick(get(group), get(p));
});
append($$anchor5, span_3);
});
append($$anchor4, div_7);
};
if_block(node_5, ($$render) => {
if (get(precs).length) $$render(consequent_4);
});
}
template_effect(() => {
var _a, _b, _c, _d, _e, _f;
classes_1 = set_class(div_5, 1, "pcr-picker-item svelte-ycwcax", null, classes_1, {
@@ -775,123 +849,148 @@ function ModelPicker($$anchor, $$props) {
event("pointerleave", div_5, onItemLeave);
append($$anchor3, div_5);
});
var node_5 = sibling(node_2, 2);
each(node_5, 17, () => get(catalogInstalled), index, ($$anchor3, entry) => {
var node_6 = sibling(node_2, 2);
each(node_6, 17, () => get(catalogInstalled), index, ($$anchor3, entry) => {
const precisions = user_derived(() => extractPrecisions(get(entry).files || []));
const status = user_derived(() => get(catalogPrecisionStatus)[get(entry).hash] || {});
var div_7 = root_7$2();
var div_8 = child(div_7);
var span_3 = child(div_8);
var text_5 = child(span_3);
var div_9 = sibling(span_3, 2);
each(div_9, 21, () => get(precisions), index, ($$anchor4, prec) => {
const installed = user_derived(() => !!get(status)[get(prec)]);
var span_4 = root_8$2();
let classes_2;
var text_6 = child(span_4);
var div_8 = root_9$2();
let classes_3;
var div_9 = child(div_8);
var span_4 = child(div_9);
var text_6 = child(span_4);
var div_10 = sibling(span_4, 2);
each(div_10, 21, () => get(precisions), index, ($$anchor4, prec) => {
const st = user_derived(() => get(status)[get(prec)] || "missing");
var span_5 = root_10$2();
let classes_4;
var text_7 = child(span_5);
template_effect(
($0, $1) => {
classes_2 = set_class(span_4, 1, "pcr-picker-precision-tag svelte-ycwcax", null, classes_2, {
"pcr-precision-installed": get(installed),
"pcr-precision-missing": !get(installed)
classes_4 = set_class(span_5, 1, "pcr-picker-precision-tag svelte-ycwcax", null, classes_4, {
"pcr-precision-installed": get(st) === "installed",
"pcr-precision-partial": get(st) === "partial",
"pcr-precision-missing": get(st) === "missing"
});
set_attribute(span_4, "title", $0);
set_text(text_6, `${$1 ?? ""}${get(installed) ? "" : " ↓"}`);
set_attribute(span_5, "title", $0);
set_text(text_7, `${$1 ?? ""}${get(st) === "installed" ? "" : get(st) === "partial" ? " ↻" : " ↓"}`);
},
[
() => get(installed) ? `${get(prec).toUpperCase()} installed` : `Download ${get(prec).toUpperCase()}`,
() => get(st) === "installed" ? `${get(prec).toUpperCase()} installed` : get(st) === "partial" ? `${get(prec).toUpperCase()} download unfinished — click to resume` : `Download ${get(prec).toUpperCase()}`,
() => get(prec).toUpperCase()
]
);
delegated("click", span_4, (e) => {
delegated("click", span_5, (e) => {
e.stopPropagation();
handleCatalogPrecisionClick(get(entry), get(prec), get(installed));
handleCatalogPrecisionClick(get(entry), get(prec), get(st));
});
append($$anchor4, span_4);
append($$anchor4, span_5);
});
template_effect(() => set_text(text_5, get(entry).display_name || get(entry).model_name));
delegated("click", div_7, (e) => {
template_effect(
($0) => {
classes_3 = set_class(div_8, 1, "pcr-picker-item pcr-picker-catalog-item svelte-ycwcax", null, classes_3, $0);
set_text(text_6, get(entry).display_name || get(entry).model_name);
},
[
() => ({
"pcr-picker-catalog-partial": get(precisions).some((p) => get(status)[p] === "partial")
})
]
);
delegated("click", div_8, (e) => {
e.stopPropagation();
const installedPrec = get(precisions).find((p) => get(status)[p]);
if (installedPrec) handleCatalogPrecisionClick(get(entry), installedPrec, true);
const prec = get(precisions).find((p) => get(status)[p] === "installed") || get(precisions).find((p) => get(status)[p] === "partial");
if (prec) handleCatalogPrecisionClick(get(entry), prec, get(status)[prec]);
});
append($$anchor3, div_7);
append($$anchor3, div_8);
});
var node_6 = sibling(node_5, 2);
var node_7 = sibling(node_6, 2);
{
var consequent_6 = ($$anchor3) => {
var fragment_2 = root_9$2();
var node_7 = sibling(first_child(fragment_2), 2);
each(node_7, 17, () => get(catalogGroups), (group) => group.name, ($$anchor4, group) => {
var consequent_8 = ($$anchor3) => {
var fragment_2 = root_11$1();
var node_8 = sibling(first_child(fragment_2), 2);
each(node_8, 17, () => get(catalogGroups), (group) => group.name, ($$anchor4, group) => {
const meta = user_derived(() => [get(group).architecture, get(group).family].filter(Boolean).join(" · "));
var div_10 = root_10$2();
let classes_3;
var div_11 = child(div_10);
var span_5 = child(div_11);
var text_7 = child(span_5);
var node_8 = sibling(text_7);
{
var consequent_4 = ($$anchor5) => {
var span_6 = root_11$1();
var text_8 = child(span_6);
template_effect(() => set_text(text_8, get(group).versions.length));
append($$anchor5, span_6);
};
if_block(node_8, ($$render) => {
if (get(group).versions.length > 1) $$render(consequent_4);
});
}
var node_9 = sibling(span_5, 2);
var div_11 = root_12();
let classes_5;
var div_12 = child(div_11);
var span_6 = child(div_12);
var text_8 = child(span_6);
var node_9 = sibling(text_8);
{
var consequent_5 = ($$anchor5) => {
var span_7 = root_12();
var span_7 = root_13();
var text_9 = child(span_7);
template_effect(() => set_text(text_9, get(meta)));
template_effect(() => set_text(text_9, get(group).versions.length));
append($$anchor5, span_7);
};
if_block(node_9, ($$render) => {
if (get(meta)) $$render(consequent_5);
if (get(group).versions.length > 1) $$render(consequent_5);
});
}
var span_8 = sibling(div_11, 2);
var text_10 = child(span_8);
var node_10 = sibling(span_6, 2);
{
var consequent_7 = ($$anchor5) => {
var span_8 = root_14();
var text_10 = child(span_8);
var node_11 = sibling(text_10);
{
var consequent_6 = ($$anchor6) => {
var fragment_3 = root_15();
var text_11 = first_child(fragment_3, true);
template_effect(() => set_text(text_11, get(meta) ? " · " : ""));
append($$anchor6, fragment_3);
};
if_block(node_11, ($$render) => {
if (get(group).partial) $$render(consequent_6);
});
}
template_effect(() => set_text(text_10, get(meta)));
append($$anchor5, span_8);
};
if_block(node_10, ($$render) => {
if (get(meta) || get(group).partial) $$render(consequent_7);
});
}
var span_9 = sibling(div_12, 2);
var text_12 = child(span_9);
template_effect(() => {
var _a, _b;
classes_3 = set_class(div_10, 1, "pcr-picker-item pcr-picker-catalog-item svelte-ycwcax", null, classes_3, {
"pcr-picker-item-active": ((_a = get(activeSubmenu)) == null ? void 0 : _a.type) === "catalogVersion" && ((_b = get(activeSubmenu)) == null ? void 0 : _b.group) === get(group)
classes_5 = set_class(div_11, 1, "pcr-picker-item pcr-picker-catalog-item svelte-ycwcax", null, classes_5, {
"pcr-picker-item-active": ((_a = get(activeSubmenu)) == null ? void 0 : _a.type) === "catalogVersion" && ((_b = get(activeSubmenu)) == null ? void 0 : _b.group) === get(group),
"pcr-picker-catalog-partial": get(group).partial
});
set_text(text_7, `${get(group).name ?? ""} `);
set_text(text_10, get(group).versions.length > 1 ? "▸" : "↓");
set_text(text_8, `${get(group).name ?? ""} `);
set_text(text_12, get(group).versions.length > 1 ? "▸" : get(group).partial ? "↻" : "↓");
});
delegated("click", div_10, (e) => {
delegated("click", div_11, (e) => {
e.stopPropagation();
handleCatalogGroupClick(get(group));
});
append($$anchor4, div_10);
append($$anchor4, div_11);
});
append($$anchor3, fragment_2);
};
if_block(node_6, ($$render) => {
if (get(catalogGroups).length) $$render(consequent_6);
if_block(node_7, ($$render) => {
if (get(catalogGroups).length) $$render(consequent_8);
});
}
var node_10 = sibling(node_6, 2);
var node_12 = sibling(node_7, 2);
{
var consequent_10 = ($$anchor3) => {
var fragment_3 = root_13();
var div_12 = sibling(first_child(fragment_3), 2);
var node_11 = child(div_12);
each(node_11, 17, () => get(civitaiResults), index, ($$anchor4, result) => {
var div_13 = root_14();
var div_14 = child(div_13);
var span_9 = child(div_14);
var text_11 = child(span_9);
var span_10 = sibling(span_9, 2);
var text_12 = child(span_10);
var consequent_12 = ($$anchor3) => {
var fragment_4 = root_16();
var div_13 = sibling(first_child(fragment_4), 2);
var node_13 = child(div_13);
each(node_13, 17, () => get(civitaiResults), index, ($$anchor4, result) => {
var div_14 = root_17();
var div_15 = child(div_14);
var span_10 = child(div_15);
var text_13 = child(span_10);
var span_11 = sibling(span_10, 2);
var text_14 = child(span_11);
template_effect(
($0) => {
set_text(text_11, get(result).model_name);
set_text(text_12, $0);
set_text(text_13, get(result).model_name);
set_text(text_14, $0);
},
[
() => {
@@ -905,8 +1004,8 @@ function ModelPicker($$anchor, $$props) {
}
]
);
delegated("click", div_13, () => $$props.onShowDownload(get(result)));
event("pointerenter", div_13, (e) => onItemEnter(e, {
delegated("click", div_14, () => $$props.onShowDownload(get(result)));
event("pointerenter", div_14, (e) => onItemEnter(e, {
thumbnail: get(result).thumbnail,
name: get(result).model_name,
version: get(result).version,
@@ -915,44 +1014,44 @@ function ModelPicker($$anchor, $$props) {
downloads: get(result).downloads,
tags: get(result).tags
}));
event("pointerleave", div_13, onItemLeave);
append($$anchor4, div_13);
event("pointerleave", div_14, onItemLeave);
append($$anchor4, div_14);
});
var node_12 = sibling(node_11, 2);
var node_14 = sibling(node_13, 2);
{
var consequent_7 = ($$anchor4) => {
var div_15 = root_15();
append($$anchor4, div_15);
};
var consequent_8 = ($$anchor4) => {
var div_16 = root_16();
var consequent_9 = ($$anchor4) => {
var div_16 = root_18();
append($$anchor4, div_16);
};
var consequent_9 = ($$anchor4) => {
var div_17 = root_17();
var consequent_10 = ($$anchor4) => {
var div_17 = root_19();
append($$anchor4, div_17);
};
if_block(node_12, ($$render) => {
if (get(civitaiLoading) && !get(civitaiResults).length) $$render(consequent_7);
else if (get(civitaiLoading)) $$render(consequent_8, 1);
else if (get(civitaiHasSearched) && !get(civitaiResults).length) $$render(consequent_9, 2);
var consequent_11 = ($$anchor4) => {
var div_18 = root_20();
append($$anchor4, div_18);
};
if_block(node_14, ($$render) => {
if (get(civitaiLoading) && !get(civitaiResults).length) $$render(consequent_9);
else if (get(civitaiLoading)) $$render(consequent_10, 1);
else if (get(civitaiHasSearched) && !get(civitaiResults).length) $$render(consequent_11, 2);
});
}
append($$anchor3, fragment_3);
append($$anchor3, fragment_4);
};
var d = user_derived(() => get(searchQuery).trim() && (get(civitaiResults).length || get(civitaiLoading) || get(civitaiHasSearched)));
if_block(node_10, ($$render) => {
if (get(d)) $$render(consequent_10);
if_block(node_12, ($$render) => {
if (get(d)) $$render(consequent_12);
});
}
var node_13 = sibling(node_10, 2);
var node_15 = sibling(node_12, 2);
{
var consequent_11 = ($$anchor3) => {
var div_18 = root_18();
append($$anchor3, div_18);
var consequent_13 = ($$anchor3) => {
var div_19 = root_21();
append($$anchor3, div_19);
};
if_block(node_13, ($$render) => {
if (!get(filteredGroups).length && !get(catalogInstalled).length && !get(catalogUninstalled).length && !get(civitaiResults).length && !get(civitaiLoading) && !get(civitaiHasSearched) && !get(loading)) $$render(consequent_11);
if_block(node_15, ($$render) => {
if (!get(filteredGroups).length && !get(catalogInstalled).length && !get(catalogUninstalled).length && !get(civitaiResults).length && !get(civitaiLoading) && !get(civitaiHasSearched) && !get(loading)) $$render(consequent_13);
});
}
bind_this(div_4, ($$value) => listEl = $$value, () => listEl);
@@ -972,61 +1071,61 @@ function ModelPicker($$anchor, $$props) {
});
}
bind_this(div, ($$value) => pickerEl = $$value, () => pickerEl);
var node_14 = sibling(div, 2);
var node_16 = sibling(div, 2);
{
var consequent_12 = ($$anchor2) => {
var consequent_14 = ($$anchor2) => {
const group = user_derived(() => get(activeSubmenu).group);
var div_19 = root_19();
var node_15 = sibling(child(div_19), 2);
each(node_15, 17, () => get(group).versions, index, ($$anchor3, model) => {
var div_20 = root_22();
var node_17 = sibling(child(div_20), 2);
each(node_17, 17, () => get(group).versions, index, ($$anchor3, model) => {
const label = user_derived(() => buildVersionLabel(get(model)));
var div_20 = root_20();
let classes_4;
var span_11 = child(div_20);
var text_13 = child(span_11);
var div_21 = root_23();
let classes_6;
var span_12 = child(div_21);
var text_15 = child(span_12);
template_effect(() => {
var _a, _b;
classes_4 = set_class(div_20, 1, "pcr-picker-version-item svelte-ycwcax", null, classes_4, {
classes_6 = set_class(div_21, 1, "pcr-picker-version-item svelte-ycwcax", null, classes_6, {
"pcr-submenu-open": ((_b = (_a = get(activeNestedSubmenu)) == null ? void 0 : _a.model) == null ? void 0 : _b.hash) === get(model).hash
});
set_text(text_13, get(label));
set_text(text_15, get(label));
});
delegated("click", div_20, () => handleVersionClick(get(model), get(label)));
append($$anchor3, div_20);
delegated("click", div_21, () => handleVersionClick(get(model), get(label)));
append($$anchor3, div_21);
});
template_effect(() => set_style(div_19, get(submenuStyle)));
append($$anchor2, div_19);
};
if_block(node_14, ($$render) => {
var _a;
if (((_a = get(activeSubmenu)) == null ? void 0 : _a.type) === "version") $$render(consequent_12);
});
}
var node_16 = sibling(node_14, 2);
{
var consequent_13 = ($$anchor2) => {
const group = user_derived(() => get(activeSubmenu).group);
var div_21 = root_21();
var node_17 = sibling(child(div_21), 2);
each(node_17, 17, () => get(group).versions, index, ($$anchor3, entry) => {
var div_22 = root_22();
var span_12 = child(div_22);
var text_14 = child(span_12);
template_effect(($0) => set_text(text_14, $0), [() => buildCatalogVersionLabel(get(entry))]);
delegated("click", div_22, () => handleCatalogVersionClick(get(entry)));
append($$anchor3, div_22);
});
template_effect(() => set_style(div_21, get(submenuStyle)));
append($$anchor2, div_21);
template_effect(() => set_style(div_20, get(submenuStyle)));
append($$anchor2, div_20);
};
if_block(node_16, ($$render) => {
var _a;
if (((_a = get(activeSubmenu)) == null ? void 0 : _a.type) === "catalogVersion") $$render(consequent_13);
if (((_a = get(activeSubmenu)) == null ? void 0 : _a.type) === "version") $$render(consequent_14);
});
}
var node_18 = sibling(node_16, 2);
{
var consequent_19 = ($$anchor2) => {
var consequent_15 = ($$anchor2) => {
const group = user_derived(() => get(activeSubmenu).group);
var div_22 = root_24();
var node_19 = sibling(child(div_22), 2);
each(node_19, 17, () => get(group).versions, index, ($$anchor3, entry) => {
var div_23 = root_25();
var span_13 = child(div_23);
var text_16 = child(span_13);
template_effect(($0) => set_text(text_16, $0), [() => buildCatalogVersionLabel(get(entry))]);
delegated("click", div_23, () => handleCatalogVersionClick(get(entry)));
append($$anchor3, div_23);
});
template_effect(() => set_style(div_22, get(submenuStyle)));
append($$anchor2, div_22);
};
if_block(node_18, ($$render) => {
var _a;
if (((_a = get(activeSubmenu)) == null ? void 0 : _a.type) === "catalogVersion") $$render(consequent_15);
});
}
var node_20 = sibling(node_18, 2);
{
var consequent_21 = ($$anchor2) => {
const model = user_derived(() => {
var _a, _b;
return ((_a = get(activeNestedSubmenu)) == null ? void 0 : _a.model) || ((_b = get(activeSubmenu)) == null ? void 0 : _b.model);
@@ -1036,106 +1135,106 @@ function ModelPicker($$anchor, $$props) {
var _a, _b;
return ((_a = get(activeNestedSubmenu)) == null ? void 0 : _a.versionLabel) || ((_b = get(activeSubmenu)) == null ? void 0 : _b.displayName) || "";
});
var div_23 = root_23();
var div_24 = child(div_23);
var text_15 = child(div_24);
var node_19 = sibling(div_24, 2);
var div_24 = root_26();
var div_25 = child(div_24);
var text_17 = child(div_25);
var node_21 = sibling(div_25, 2);
{
var consequent_15 = ($$anchor3) => {
var fragment_4 = root_24();
var div_25 = first_child(fragment_4);
var node_20 = sibling(div_25, 2);
var consequent_17 = ($$anchor3) => {
var fragment_5 = root_27();
var div_26 = first_child(fragment_5);
var node_22 = sibling(div_26, 2);
{
var consequent_14 = ($$anchor4) => {
var div_26 = root_25();
append($$anchor4, div_26);
var consequent_16 = ($$anchor4) => {
var div_27 = root_28();
append($$anchor4, div_27);
};
if_block(node_20, ($$render) => {
if (get(tpls).length) $$render(consequent_14);
if_block(node_22, ($$render) => {
if (get(tpls).length) $$render(consequent_16);
});
}
delegated("click", div_25, () => {
delegated("click", div_26, () => {
var _a;
onSwapInPlace()(get(model).filename, get(model).hash);
(_a = $$props.onClose) == null ? void 0 : _a.call($$props);
});
append($$anchor3, fragment_4);
};
var d_1 = user_derived(() => canSwapInPlace(get(model)));
if_block(node_19, ($$render) => {
if (get(d_1)) $$render(consequent_15);
});
}
var node_21 = sibling(node_19, 2);
{
var consequent_18 = ($$anchor3) => {
const cats = user_derived(() => [...new Set(get(tpls).map((t) => t.category || "General"))]);
const sortedCats = user_derived(() => sortCategories(get(cats)));
var fragment_5 = comment();
var node_22 = first_child(fragment_5);
each(node_22, 17, () => get(sortedCats), index, ($$anchor4, cat) => {
const catTpls = user_derived(() => get(tpls).filter((t) => (t.category || "General") === get(cat)));
var fragment_6 = comment();
var node_23 = first_child(fragment_6);
{
var consequent_17 = ($$anchor5) => {
var fragment_7 = root_28();
var node_24 = first_child(fragment_7);
{
var consequent_16 = ($$anchor6) => {
var div_27 = root_29();
var text_16 = child(div_27);
template_effect(($0) => set_text(text_16, $0), [() => get(cat).toUpperCase()]);
append($$anchor6, div_27);
};
if_block(node_24, ($$render) => {
if (get(sortedCats).length > 1) $$render(consequent_16);
});
}
var node_25 = sibling(node_24, 2);
each(node_25, 17, () => get(catTpls), index, ($$anchor6, tpl) => {
var div_28 = root_30();
var text_17 = child(div_28);
template_effect(() => {
set_attribute(div_28, "title", get(tpl).description || "");
set_text(text_17, get(tpl).name);
});
delegated("click", div_28, () => handleTemplateClick(get(tpl), get(model)));
append($$anchor6, div_28);
});
append($$anchor5, fragment_7);
};
if_block(node_23, ($$render) => {
if (get(catTpls).length) $$render(consequent_17);
});
}
append($$anchor4, fragment_6);
});
append($$anchor3, fragment_5);
};
var alternate_1 = ($$anchor3) => {
var div_29 = root_31();
append($$anchor3, div_29);
};
var d_1 = user_derived(() => canSwapInPlace(get(model)));
if_block(node_21, ($$render) => {
if (get(tpls).length) $$render(consequent_18);
if (get(d_1)) $$render(consequent_17);
});
}
var node_23 = sibling(node_21, 2);
{
var consequent_20 = ($$anchor3) => {
const cats = user_derived(() => [...new Set(get(tpls).map((t) => t.category || "General"))]);
const sortedCats = user_derived(() => sortCategories(get(cats)));
var fragment_6 = comment();
var node_24 = first_child(fragment_6);
each(node_24, 17, () => get(sortedCats), index, ($$anchor4, cat) => {
const catTpls = user_derived(() => get(tpls).filter((t) => (t.category || "General") === get(cat)));
var fragment_7 = comment();
var node_25 = first_child(fragment_7);
{
var consequent_19 = ($$anchor5) => {
var fragment_8 = root_31();
var node_26 = first_child(fragment_8);
{
var consequent_18 = ($$anchor6) => {
var div_28 = root_32();
var text_18 = child(div_28);
template_effect(($0) => set_text(text_18, $0), [() => get(cat).toUpperCase()]);
append($$anchor6, div_28);
};
if_block(node_26, ($$render) => {
if (get(sortedCats).length > 1) $$render(consequent_18);
});
}
var node_27 = sibling(node_26, 2);
each(node_27, 17, () => get(catTpls), index, ($$anchor6, tpl) => {
var div_29 = root_33();
var text_19 = child(div_29);
template_effect(() => {
set_attribute(div_29, "title", get(tpl).description || "");
set_text(text_19, get(tpl).name);
});
delegated("click", div_29, () => handleTemplateClick(get(tpl), get(model)));
append($$anchor6, div_29);
});
append($$anchor5, fragment_8);
};
if_block(node_25, ($$render) => {
if (get(catTpls).length) $$render(consequent_19);
});
}
append($$anchor4, fragment_7);
});
append($$anchor3, fragment_6);
};
var alternate_1 = ($$anchor3) => {
var div_30 = root_34();
append($$anchor3, div_30);
};
if_block(node_23, ($$render) => {
if (get(tpls).length) $$render(consequent_20);
else $$render(alternate_1, -1);
});
}
template_effect(() => {
set_style(div_23, get(activeNestedSubmenu) ? get(nestedSubmenuStyle) : get(submenuStyle));
set_text(text_15, get(displayName));
set_style(div_24, get(activeNestedSubmenu) ? get(nestedSubmenuStyle) : get(submenuStyle));
set_text(text_17, get(displayName));
});
append($$anchor2, div_23);
append($$anchor2, div_24);
};
if_block(node_18, ($$render) => {
if_block(node_20, ($$render) => {
var _a;
if (((_a = get(activeSubmenu)) == null ? void 0 : _a.type) === "template" || get(activeNestedSubmenu)) $$render(consequent_19);
if (((_a = get(activeSubmenu)) == null ? void 0 : _a.type) === "template" || get(activeNestedSubmenu)) $$render(consequent_21);
});
}
var node_26 = sibling(node_18, 2);
var node_28 = sibling(node_20, 2);
bind_this(
HoverCard(node_26, {
HoverCard(node_28, {
get pickerEl() {
return pickerEl;
}
@@ -1481,6 +1580,7 @@ var root_10$1 = from_html(`<button class="pcr-download-btn svelte-n0tzlo"> </but
var root$1 = from_html(`<div class="pcr-download-overlay svelte-n0tzlo"><div class="pcr-download-modal svelte-n0tzlo"><div class="pcr-download-header svelte-n0tzlo"><div class="pcr-download-title svelte-n0tzlo"> </div> <div class="pcr-download-meta svelte-n0tzlo"> </div> <!></div> <!> <div class="pcr-download-body svelte-n0tzlo"></div> <!> <div class="pcr-download-footer svelte-n0tzlo"><div class="pcr-download-status svelte-n0tzlo"> </div> <div class="pcr-download-buttons svelte-n0tzlo"><!> <!></div></div></div></div>`);
function CatalogDownloadModal($$anchor, $$props) {
push($$props, true);
let initialPrecision = prop($$props, "initialPrecision", 3, null);
function resolveFileDownloadUrl(file) {
if (file.download_url) return file.download_url;
const source = file.source;
@@ -1518,7 +1618,7 @@ function CatalogDownloadModal($$anchor, $$props) {
}
const rawFiles = $$props.catalogEntry.files || [];
const precisions = extractPrecisions(rawFiles);
let selectedPrecision = state(proxy(precisions[0] || null));
let selectedPrecision = state(proxy(initialPrecision() && precisions.includes(initialPrecision()) ? initialPrecision() : precisions[0] || null));
let resolvedFiles = user_derived(() => get(selectedPrecision) ? resolveFilesForPrecision(rawFiles, get(selectedPrecision)) : rawFiles);
let fileStatuses = state(proxy({}));
let downloading = state(false);
+62 -41
View File
@@ -1,6 +1,6 @@
import { d as delegate, p as push, a as prop, s as state, c as proxy, u as user_effect, e as set, f as sibling, o as bind_this, t as template_effect, g as get, x as set_attribute, q as set_value, j as delegated, k as append, l as pop, m as user_derived, n as child, A as from_html, w as each, z as index, i as if_block, G as set_checked, y as set_text, h as set_class, v as first_child, D as comment, I as to_array, a1 as set_selected, R as text, E as untrack, M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { F as FAMILIES, A as ARCHITECTURES } from "./model-constants-DOP7d958.js";
import { F as FAMILIES, A as ARCHITECTURES } from "./model-constants-cVxhUf51.js";
import { S as SettingsSlider } from "./SettingsSlider-CKF_XmgB.js";
import { b as bind_value } from "./input-DFQhebEz.js";
import { b as bind_select_value } from "./select-Dp4ExMMc.js";
@@ -384,6 +384,17 @@ function SettingsTab($$anchor, $$props) {
if (!get(editors)[nodeType]) get(editors)[nodeType] = {};
get(editors)[nodeType][widgetName] = val;
}
function mirrorIdeogramScheduler(latentNode) {
const sched = detected().find((d) => d.type === "Ideogram4Scheduler");
if (!sched) return;
for (const dim of ["width", "height"]) {
const v = $$props.readWidgetValue(latentNode, dim);
if (v !== void 0) {
$$props.writeWidgetValue(sched.node, dim, v);
setEditorValue("Ideogram4Scheduler", dim, v);
}
}
}
function setRangeValue(nodeType, widgetName, lo, hi) {
if (!rangeValues[nodeType]) rangeValues[nodeType] = {};
rangeValues[nodeType][`${widgetName}_range`] = [lo, hi];
@@ -397,6 +408,17 @@ function SettingsTab($$anchor, $$props) {
function togglePresetMenu(nodeType) {
presetMenuOpen[nodeType] = !presetMenuOpen[nodeType];
}
user_effect(() => {
function onDocDown(e) {
var _a2, _b2;
if ((_b2 = (_a2 = e.target) == null ? void 0 : _a2.closest) == null ? void 0 : _b2.call(_a2, ".pcr-resolution-preset-wrap")) return;
for (const k of Object.keys(presetMenuOpen)) {
if (presetMenuOpen[k]) presetMenuOpen[k] = false;
}
}
document.addEventListener("pointerdown", onDocDown, true);
return () => document.removeEventListener("pointerdown", onDocDown, true);
});
let expandedState = proxy({ ...expandedSections() });
function toggleSection(type) {
expandedState[type] = !expandedState[type];
@@ -557,13 +579,23 @@ function SettingsTab($$anchor, $$props) {
var node_8 = first_child(fragment_2);
{
var consequent_8 = ($$anchor4) => {
const w = user_derived(() => $$props.readWidgetValue(node(), "width"));
const h = user_derived(() => $$props.readWidgetValue(node(), "height"));
const w = user_derived(() => {
var _a2;
return (_a2 = get(editors)[type()]) == null ? void 0 : _a2.width;
});
const h = user_derived(() => {
var _a2;
return (_a2 = get(editors)[type()]) == null ? void 0 : _a2.height;
});
var fragment_3 = comment();
var node_9 = first_child(fragment_3);
{
var consequent_7 = ($$anchor5) => {
const presets = user_derived(() => get(savedWidgets)["_resolutions"] || []);
const presets = user_derived(() => {
var _a2;
return ((_a2 = get(editors)[type()]) == null ? void 0 : _a2["_resolutions"]) || [];
});
const isPreset = user_derived(() => get(presets).some((p) => p.width === get(w) && p.height === get(h)));
var div_6 = root_10$2();
var div_7 = sibling(child(div_6), 2);
var div_8 = child(div_7);
@@ -602,6 +634,7 @@ function SettingsTab($$anchor, $$props) {
$$props.writeWidgetValue(node(), "height", get(p).height);
setEditorValue(type(), "width", get(p).width);
setEditorValue(type(), "height", get(p).height);
mirrorIdeogramScheduler(node());
presetMenuOpen[type()] = false;
});
delegated("click", span_2, (e) => {
@@ -622,22 +655,13 @@ function SettingsTab($$anchor, $$props) {
var div_12 = sibling(node_11, 2);
let classes_2;
var text_6 = child(div_12);
template_effect(
($0, $1) => {
classes_2 = set_class(div_12, 1, "pcr-resolution-preset-add svelte-82atyh", null, classes_2, $0);
set_text(text_6, $1);
},
[
() => ({
"pcr-resolution-preset-add-disabled": get(presets).some((p) => p.width === get(w) && p.height === get(h))
}),
() => get(presets).some((p) => p.width === get(w) && p.height === get(h)) ? `${get(w)}×${get(h)} already saved` : "+ Add Current"
]
);
template_effect(() => {
classes_2 = set_class(div_12, 1, "pcr-resolution-preset-add svelte-82atyh", null, classes_2, { "pcr-resolution-preset-add-disabled": get(isPreset) });
set_text(text_6, get(isPreset) ? `${get(w)}×${get(h)} already saved` : "+ Add Current");
});
delegated("click", div_12, () => {
if (!get(presets).some((p) => p.width === get(w) && p.height === get(h))) {
get(presets).push({ width: get(w), height: get(h) });
get(editors)[type()]["_resolutions"] = [...get(presets)];
if (!get(isPreset)) {
get(editors)[type()]["_resolutions"] = [...get(presets), { width: get(w), height: get(h) }];
}
presetMenuOpen[type()] = false;
});
@@ -653,28 +677,23 @@ function SettingsTab($$anchor, $$props) {
var input_2 = sibling(input_1, 4);
let classes_4;
set_attribute(input_2, "step", 8);
template_effect(
($0, $1, $2, $3) => {
classes = set_class(button_1, 1, "pcr-resolution-preset-btn svelte-82atyh", null, classes, $0);
set_text(text_3, $1);
classes_3 = set_class(input_1, 1, "pcr-resolution-input svelte-82atyh", null, classes_3, $2);
set_value(input_1, get(w));
classes_4 = set_class(input_2, 1, "pcr-resolution-input svelte-82atyh", null, classes_4, $3);
set_value(input_2, get(h));
},
[
() => ({
"pcr-resolution-preset-active": get(presets).some((p) => p.width === get(w) && p.height === get(h))
}),
() => get(presets).some((p) => p.width === get(w) && p.height === get(h)) ? `${get(w)}×${get(h)}` : "Presets",
() => ({
"pcr-resolution-input-dimmed": get(presets).some((p) => p.width === get(w) && p.height === get(h))
}),
() => ({
"pcr-resolution-input-dimmed": get(presets).some((p) => p.width === get(w) && p.height === get(h))
})
]
);
template_effect(() => {
classes = set_class(button_1, 1, "pcr-resolution-preset-btn svelte-82atyh", null, classes, {
"pcr-resolution-preset-active": get(isPreset),
"pcr-resolution-preset-dimmed": !get(isPreset)
});
set_text(text_3, get(isPreset) ? `${get(w)}×${get(h)}` : "Presets");
classes_3 = set_class(input_1, 1, "pcr-resolution-input svelte-82atyh", null, classes_3, {
"pcr-resolution-input-dimmed": get(isPreset),
"pcr-resolution-input-active": !get(isPreset)
});
set_value(input_1, get(w));
classes_4 = set_class(input_2, 1, "pcr-resolution-input svelte-82atyh", null, classes_4, {
"pcr-resolution-input-dimmed": get(isPreset),
"pcr-resolution-input-active": !get(isPreset)
});
set_value(input_2, get(h));
});
delegated("click", button_1, (e) => {
e.stopPropagation();
togglePresetMenu(type());
@@ -683,11 +702,13 @@ function SettingsTab($$anchor, $$props) {
const v = parseInt(e.target.value) || 512;
setEditorValue(type(), "width", v);
$$props.writeWidgetValue(node(), "width", v);
mirrorIdeogramScheduler(node());
});
delegated("change", input_2, (e) => {
const v = parseInt(e.target.value) || 512;
setEditorValue(type(), "height", v);
$$props.writeWidgetValue(node(), "height", v);
mirrorIdeogramScheduler(node());
});
append($$anchor5, div_6);
};
+352 -55
View File
@@ -4,19 +4,19 @@ var __typeError = (msg) => {
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var _mode, _switchIndex, _locked, _disabled, _collapsed, _connectedCount, _hasLabels, _switchLabel, _iterateCurrent, _iterateTotal, _iterateCycle, _switchOptions, _outputPanelOpen, _imagePanelVisible, _aiAssistantOpen, _posePanelVisible, _hasPoseStudio, _compiledOutput, _compiledNegOutput, _imageUrl, _previewUrl, _progress, _isGenerating, _rollSelected;
var _mode, _switchIndex, _locked, _disabled, _collapsed, _connectedCount, _hasLabels, _switchLabel, _iterateCurrent, _iterateTotal, _iterateCycle, _switchOptions, _outputPanelOpen, _imagePanelVisible, _aiAssistantOpen, _posePanelVisible, _regionPanelVisible, _hasPoseStudio, _hasRegionBox, _compiledOutput, _compiledNegOutput, _imageUrl, _previewUrl, _progress, _isGenerating, _rollSelected;
import { p as push, o as bind_this, k as append, l as pop, A as from_html, d as delegate, a as prop, g as get, j as delegated, m as user_derived, s as state, c as proxy, u as user_effect, i as if_block, f as sibling, r as event, e as set, C as tick, n as child, D as comment, v as first_child, w as each, t as template_effect, y as set_text, h as set_class, x as set_attribute, Q as clsx, z as index, R as text, E as untrack, M as unmount, L as mount } from "./disclose-version-uq4tn5Y6.js";
import { u as useApi, p as provideApi } from "./api-context-BFKo1mCD.js";
import { M as Menubar } from "./Menubar-DI-z1NzN.js";
import { M as Menubar } from "./Menubar-DC12j8gE.js";
import { o as onDestroy, a as onMount } from "./index-client-iMRCrpBY.js";
import { s as set_style } from "./style-Boi27oOu.js";
import { h as html, P as PopupAnchor } from "./PopupAnchor-C6OWGGTI.js";
import { api } from "/scripts/api.js";
import { c as clampGalleryRowHeight, z as zoomGalleryRowHeight, G as GeneratedGallery } from "./GeneratedGallery-Dw0CC3-N.js";
import { c as clampGalleryRowHeight, z as zoomGalleryRowHeight, G as GeneratedGallery } from "./GeneratedGallery-D5XbkMk7.js";
import { b as bind_value } from "./input-DFQhebEz.js";
import { app } from "/scripts/app.js";
import { r as readNodePrompt, c as cryptoId, e as extractNGrams, m as matchCharactersInDb, i as isStandaloneMainPromptChain, a as applyPromptText } from "./ai-patch-helpers-Bayqv0oF.js";
var root$c = from_html(`<div class="pcr-editor-frame"></div>`);
var root$d = from_html(`<div class="pcr-editor-frame"></div>`);
function EditorPane($$anchor, $$props) {
push($$props, true);
let editorContainer;
@@ -26,12 +26,12 @@ function EditorPane($$anchor, $$props) {
function setEditorView(view) {
}
var $$exports = { getContainer, setEditorView };
var div = root$c();
var div = root$d();
bind_this(div, ($$value) => editorContainer = $$value, () => editorContainer);
append($$anchor, div);
return pop($$exports);
}
var root$b = from_html(`<div class="pcr-output-panel-content pcr-scrollable svelte-qbaa69" tabindex="0"></div>`);
var root$c = from_html(`<div class="pcr-output-panel-content pcr-scrollable svelte-qbaa69" tabindex="0"></div>`);
function PromptOutput($$anchor, $$props) {
push($$props, true);
let compiledOutput = prop($$props, "compiledOutput", 3, ""), compiledNegOutput = prop($$props, "compiledNegOutput", 3, "");
@@ -59,7 +59,7 @@ ${escapeHtml(compiledNegOutput())}`;
}
return result;
});
var div = root$b();
var div = root$c();
html(div, () => get(html$1), true);
delegated("pointerdown", div, (e) => e.stopPropagation());
delegated("mousedown", div, (e) => e.stopPropagation());
@@ -70,7 +70,7 @@ delegate(["pointerdown", "mousedown"]);
var root_1$6 = from_html(`<span class="pcr-console-placeholder svelte-1ouufnb">No log entries</span>`);
var root_3$5 = from_html(`<div class="pcr-console-line svelte-1ouufnb"><span class="pcr-console-time svelte-1ouufnb"> </span> <span class="pcr-console-msg svelte-1ouufnb"> </span></div>`);
var root_4$6 = from_html(`<div class="pcr-console-scroll-btn svelte-1ouufnb"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"></path></svg></div>`);
var root$a = from_html(`<div class="pcr-console-log pcr-scrollable svelte-1ouufnb" tabindex="0"><!> <!></div>`);
var root$b = from_html(`<div class="pcr-console-log pcr-scrollable svelte-1ouufnb" tabindex="0"><!> <!></div>`);
function ConsoleLog($$anchor, $$props) {
push($$props, true);
let active = prop($$props, "active", 3, false);
@@ -155,7 +155,7 @@ function ConsoleLog($$anchor, $$props) {
onDestroy(() => {
unsubscribe();
});
var div = root$a();
var div = root$b();
var node = child(div);
{
var consequent = ($$anchor2) => {
@@ -212,8 +212,8 @@ function ConsoleLog($$anchor, $$props) {
pop();
}
delegate(["pointerdown", "mousedown", "click"]);
var root_2$8 = from_html(`<div class="pcr-output-panel-generated pcr-scrollable svelte-1kzsw09" style="display:block"><!></div>`);
var root$9 = from_html(`<div></div> <div class="pcr-output-panel svelte-1kzsw09"><div class="pcr-output-panel-header svelte-1kzsw09"><div class="pcr-output-panel-tabs svelte-1kzsw09"><button>Prompt Output</button> <button> </button> <button>Console</button></div> <div class="pcr-output-panel-controls svelte-1kzsw09"><div class="pcr-output-panel-maximize svelte-1kzsw09"></div> <div class="pcr-output-panel-close svelte-1kzsw09" title="Close Panel"></div></div></div> <!> <div><!></div> <!></div>`, 1);
var root_2$9 = from_html(`<div class="pcr-output-panel-generated pcr-scrollable svelte-1kzsw09" style="display:block"><!></div>`);
var root$a = from_html(`<div></div> <div class="pcr-output-panel svelte-1kzsw09"><div class="pcr-output-panel-header svelte-1kzsw09"><div class="pcr-output-panel-tabs svelte-1kzsw09"><button>Prompt Output</button> <button> </button> <button>Console</button></div> <div class="pcr-output-panel-controls svelte-1kzsw09"><div class="pcr-output-panel-maximize svelte-1kzsw09"></div> <div class="pcr-output-panel-close svelte-1kzsw09" title="Close Panel"></div></div></div> <!> <div><!></div> <!></div>`, 1);
function OutputPanel($$anchor, $$props) {
var _a, _b, _c, _d, _e;
push($$props, true);
@@ -486,7 +486,7 @@ function OutputPanel($$anchor, $$props) {
});
onDestroy(() => resizeAc == null ? void 0 : resizeAc.abort());
var $$exports = { open, close, toggle, openPrompt, getIsOpen };
var fragment = root$9();
var fragment = root$a();
var div = first_child(fragment);
let classes;
let styles;
@@ -537,7 +537,7 @@ function OutputPanel($$anchor, $$props) {
var node_3 = sibling(div_7, 2);
{
var consequent_2 = ($$anchor2) => {
var div_8 = root_2$8();
var div_8 = root_2$9();
var node_4 = child(div_8);
{
var consequent_1 = ($$anchor3) => {
@@ -674,12 +674,13 @@ function OutputPanel($$anchor, $$props) {
return pop($$exports);
}
delegate(["pointerdown", "mousedown", "click"]);
var root_1$5 = from_html(`<img draggable="false" alt="" class="svelte-ro1n43"/>`);
var root_2$7 = from_html(`<div class="pcr-image-placeholder svelte-ro1n43"> </div>`);
var root_4$5 = from_html(`<span class="pcr-image-panel-progress-text svelte-ro1n43"> </span>`);
var root_3$4 = from_html(`<div><div class="pcr-image-panel-progress-track svelte-ro1n43"><div class="pcr-image-panel-progress-bar svelte-ro1n43"></div></div> <!></div>`);
var root_5$4 = from_html(`<span class="pcr-image-panel-info-text svelte-ro1n43"> </span>`);
var root$8 = from_html(`<div class="pcr-image-divider svelte-ro1n43"></div> <div class="pcr-image-panel svelte-ro1n43"><div class="pcr-image-panel-header svelte-ro1n43"><span> </span> <button class="pcr-image-panel-close-btn svelte-ro1n43" title="Close image panel"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svelte-ro1n43"><line x1="18" y1="6" x2="6" y2="18" class="svelte-ro1n43"></line><line x1="6" y1="6" x2="18" y2="18" class="svelte-ro1n43"></line></svg></button></div> <div class="pcr-image-container svelte-ro1n43"><!> <!></div> <div><!></div></div>`, 1);
var root_1$5 = from_html(`<video autoplay="" loop="" playsinline="" controls="" class="svelte-ro1n43"></video>`, 2);
var root_2$8 = from_html(`<img draggable="false" alt="" class="svelte-ro1n43"/>`);
var root_3$4 = from_html(`<div class="pcr-image-placeholder svelte-ro1n43"> </div>`);
var root_5$4 = from_html(`<span class="pcr-image-panel-progress-text svelte-ro1n43"> </span>`);
var root_4$5 = from_html(`<div><div class="pcr-image-panel-progress-track svelte-ro1n43"><div class="pcr-image-panel-progress-bar svelte-ro1n43"></div></div> <!></div>`);
var root_6$3 = from_html(`<span class="pcr-image-panel-info-text svelte-ro1n43"> </span>`);
var root$9 = from_html(`<div class="pcr-image-divider svelte-ro1n43"></div> <div class="pcr-image-panel svelte-ro1n43"><div class="pcr-image-panel-header svelte-ro1n43"><span> </span> <button class="pcr-image-panel-close-btn svelte-ro1n43" title="Close image panel"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svelte-ro1n43"><line x1="18" y1="6" x2="6" y2="18" class="svelte-ro1n43"></line><line x1="6" y1="6" x2="18" y2="18" class="svelte-ro1n43"></line></svg></button></div> <div class="pcr-image-container svelte-ro1n43"><!> <!></div> <div><!></div></div>`, 1);
function ImagePanel($$anchor, $$props) {
var _a, _b;
push($$props, true);
@@ -699,6 +700,7 @@ function ImagePanel($$anchor, $$props) {
let panelEl;
let dividerEl;
let imgEl;
let videoEl;
let imgContainerEl;
let isVisible = state(!!((_a = node().properties) == null ? void 0 : _a.pcrImagePreview));
let panelWidth = state(proxy(((_b = node().properties) == null ? void 0 : _b.pcrImagePanelWidth) || DEFAULT_WIDTH));
@@ -771,6 +773,10 @@ function ImagePanel($$anchor, $$props) {
return { filename: "", subfolder: "" };
}
}
let isVideoUrl = user_derived(() => {
if (!get(currentImageUrl)) return false;
return /\.(mp4|webm|m4v|mov)(\?|$)/i.test(parseImageUrl(get(currentImageUrl)).filename);
});
let filenameDisplay = user_derived(() => {
if (shared().isGenerating) return "Generating…";
if (!get(currentImageUrl)) return "";
@@ -828,6 +834,12 @@ function ImagePanel($$anchor, $$props) {
set(naturalHeight, (imgEl == null ? void 0 : imgEl.naturalHeight) || 0, true);
if (!get(isPreviewMode)) resetZoom();
}
function handleVideoLoaded() {
set(imageLoaded, true);
set(imageError, false);
set(naturalWidth, (videoEl == null ? void 0 : videoEl.videoWidth) || 0, true);
set(naturalHeight, (videoEl == null ? void 0 : videoEl.videoHeight) || 0, true);
}
function handleImageError() {
set(imageLoaded, false);
set(imageError, true);
@@ -1093,7 +1105,7 @@ function ImagePanel($$anchor, $$props) {
showProgressBar,
hideProgress
};
var fragment = root$8();
var fragment = root$9();
var div = first_child(fragment);
let styles;
bind_this(div, ($$value) => dividerEl = $$value, () => dividerEl);
@@ -1108,62 +1120,76 @@ function ImagePanel($$anchor, $$props) {
var node_1 = child(div_3);
{
var consequent = ($$anchor2) => {
var img_1 = root_1$5();
var video = root_1$5();
video.muted = true;
let styles_2;
bind_this(video, ($$value) => videoEl = $$value, () => videoEl);
template_effect(() => {
set_attribute(video, "src", get(currentImageUrl));
styles_2 = set_style(video, "", styles_2, { display: get(imageLoaded) ? "block" : "none" });
});
event("loadeddata", video, handleVideoLoaded);
event("error", video, handleImageError);
append($$anchor2, video);
};
var consequent_1 = ($$anchor2) => {
var img_1 = root_2$8();
let styles_3;
bind_this(img_1, ($$value) => imgEl = $$value, () => imgEl);
template_effect(() => {
set_attribute(img_1, "src", get(currentImageUrl));
styles_2 = set_style(img_1, "", styles_2, { display: get(imageLoaded) ? "block" : "none" });
styles_3 = set_style(img_1, "", styles_3, { display: get(imageLoaded) ? "block" : "none" });
});
event("load", img_1, handleImageLoad);
event("error", img_1, handleImageError);
append($$anchor2, img_1);
};
if_block(node_1, ($$render) => {
if (get(currentImageUrl)) $$render(consequent);
if (get(currentImageUrl) && get(isVideoUrl)) $$render(consequent);
else if (get(currentImageUrl)) $$render(consequent_1, 1);
});
}
var node_2 = sibling(node_1, 2);
{
var consequent_1 = ($$anchor2) => {
var div_4 = root_2$7();
var consequent_2 = ($$anchor2) => {
var div_4 = root_3$4();
var text_1 = child(div_4);
template_effect(() => set_text(text_1, get(imageError) ? "Image not found" : "No image"));
append($$anchor2, div_4);
};
if_block(node_2, ($$render) => {
if (!get(imageLoaded) || !get(currentImageUrl)) $$render(consequent_1);
if (!get(imageLoaded) || !get(currentImageUrl)) $$render(consequent_2);
});
}
bind_this(div_3, ($$value) => imgContainerEl = $$value, () => imgContainerEl);
var div_5 = sibling(div_3, 2);
let classes_1;
let styles_3;
let styles_4;
var node_3 = child(div_5);
{
var consequent_3 = ($$anchor2) => {
var div_6 = root_3$4();
var consequent_4 = ($$anchor2) => {
var div_6 = root_4$5();
let classes_2;
var div_7 = child(div_6);
var div_8 = child(div_7);
let styles_4;
let styles_5;
var node_4 = sibling(div_7, 2);
{
var consequent_2 = ($$anchor3) => {
var span_1 = root_4$5();
var consequent_3 = ($$anchor3) => {
var span_1 = root_5$4();
var text_2 = child(span_1);
template_effect(() => set_text(text_2, `${get(progressValue) ?? ""}/${get(progressMax) ?? ""}`));
append($$anchor3, span_1);
};
if_block(node_4, ($$render) => {
if (!get(progressIndeterminate)) $$render(consequent_2);
if (!get(progressIndeterminate)) $$render(consequent_3);
});
}
template_effect(() => {
classes_2 = set_class(div_6, 1, "pcr-image-panel-progress svelte-ro1n43", null, classes_2, {
"pcr-image-panel-progress-indeterminate": get(progressIndeterminate)
});
styles_4 = set_style(div_8, "", styles_4, {
styles_5 = set_style(div_8, "", styles_5, {
width: `${get(progressPercent) ?? ""}%`,
transition: `width ${progressDuration * 1.3}s linear`
});
@@ -1171,13 +1197,13 @@ function ImagePanel($$anchor, $$props) {
append($$anchor2, div_6);
};
var alternate = ($$anchor2) => {
var span_2 = root_5$4();
var span_2 = root_6$3();
var text_3 = child(span_2);
template_effect(() => set_text(text_3, get(infoText)));
append($$anchor2, span_2);
};
if_block(node_3, ($$render) => {
if (get(showProgress)) $$render(consequent_3);
if (get(showProgress)) $$render(consequent_4);
else $$render(alternate, -1);
});
}
@@ -1192,7 +1218,7 @@ function ImagePanel($$anchor, $$props) {
set_attribute(span, "title", get(filenameDisplay));
set_text(text2, get(filenameDisplay));
classes_1 = set_class(div_5, 1, "pcr-image-panel-info svelte-ro1n43", null, classes_1, { "pcr-image-panel-info-always": get(showProgress) });
styles_3 = set_style(div_5, "", styles_3, {
styles_4 = set_style(div_5, "", styles_4, {
display: get(showProgress) || get(imageLoaded) && get(currentImageUrl) ? "flex" : "none"
});
});
@@ -1234,8 +1260,8 @@ delegate([
"pointermove",
"pointerup"
]);
var root_2$6 = from_html(`<div class="pcr-pose-panel-placeholder svelte-1xr40xl">No 3D Poser node in the graph</div>`);
var root$7 = from_html(`<div class="pcr-pose-panel svelte-1xr40xl"><!> <div class="pcr-pose-panel-body svelte-1xr40xl"><!></div></div> <div class="pcr-pose-divider svelte-1xr40xl"></div>`, 1);
var root_2$7 = from_html(`<div class="pcr-pose-panel-placeholder svelte-1xr40xl">No 3D Poser node in the graph</div>`);
var root$8 = from_html(`<div class="pcr-pose-panel svelte-1xr40xl"><!> <div class="pcr-pose-panel-body svelte-1xr40xl"><!></div></div> <div class="pcr-pose-divider svelte-1xr40xl"></div>`, 1);
function PosePanel($$anchor, $$props) {
var _a, _b;
push($$props, true);
@@ -1415,6 +1441,240 @@ function PosePanel($$anchor, $$props) {
toggle,
getIsVisible
};
var fragment = root$8();
var div = first_child(fragment);
let styles;
var node_1 = child(div);
{
if_block(node_1, ($$render) => {
});
}
var div_1 = sibling(node_1, 2);
var node_2 = child(div_1);
{
var consequent_1 = ($$anchor2) => {
var div_2 = root_2$7();
append($$anchor2, div_2);
};
if_block(node_2, ($$render) => {
if (!get(hasDock)) $$render(consequent_1);
});
}
bind_this(div_1, ($$value) => bodyEl = $$value, () => bodyEl);
bind_this(div, ($$value) => panelEl = $$value, () => panelEl);
var div_3 = sibling(div, 2);
let styles_1;
bind_this(div_3, ($$value) => dividerEl = $$value, () => dividerEl);
template_effect(() => {
styles = set_style(div, "", styles, {
width: `${get(panelWidth) ?? ""}px`,
display: get(isVisible) ? "flex" : "none"
});
styles_1 = set_style(div_3, "", styles_1, { display: get(isVisible) ? "flex" : "none" });
});
delegated("pointerdown", div, (e) => e.stopPropagation());
delegated("mousedown", div, (e) => e.stopPropagation());
delegated("click", div, (e) => e.stopPropagation());
delegated("dblclick", div, (e) => e.stopPropagation());
event("pointerleave", div, onPanelLeave);
event("pointermove", div, onPanelMove, true);
delegated("dblclick", div_3, (e) => {
var _a2, _b2, _c, _d;
e.preventDefault();
e.stopPropagation();
const poser = dockedNode || getActivePoser()();
const w = (_b2 = (_a2 = poser == null ? void 0 : poser.widgets) == null ? void 0 : _a2.find((x) => x.name === "width")) == null ? void 0 : _b2.value;
const h = (_d = (_c = poser == null ? void 0 : poser.widgets) == null ? void 0 : _c.find((x) => x.name === "height")) == null ? void 0 : _d.value;
if (!w || !h) return;
const bodyH = (bodyEl == null ? void 0 : bodyEl.offsetHeight) || (panelEl == null ? void 0 : panelEl.offsetHeight) || 300;
set(panelWidth, clampPanelWidth(Math.round(bodyH * (w / h))), true);
if (node().properties) node().properties.pcrPosePanelWidth = get(panelWidth);
});
append($$anchor, fragment);
return pop($$exports);
}
delegate(["pointerdown", "mousedown", "click", "dblclick"]);
var root_2$6 = from_html(`<div class="pcr-region-panel-placeholder svelte-wos87s">No Region Box node in the graph</div>`);
var root$7 = from_html(`<div class="pcr-region-panel svelte-wos87s"><!> <div class="pcr-region-panel-body svelte-wos87s"><!></div></div> <div class="pcr-region-divider svelte-wos87s"></div>`, 1);
function RegionPanel($$anchor, $$props) {
var _a, _b;
push($$props, true);
const DEFAULT_WIDTH = 440;
const MIN_WIDTH = 240;
let node = prop($$props, "node", 7), onToggle = prop($$props, "onToggle", 3, () => {
}), onRegister = prop($$props, "onRegister", 3, null), getActiveRegionBox = prop($$props, "getActiveRegionBox", 3, () => null);
const { getCanvasScale } = useApi();
let panelEl;
let dividerEl;
let bodyEl;
let isVisible = state(!!((_a = node().properties) == null ? void 0 : _a.pcrRegionPanel));
let panelWidth = state(proxy(((_b = node().properties) == null ? void 0 : _b.pcrRegionPanelWidth) || DEFAULT_WIDTH));
let dockedNode = null;
let hasDock = state(false);
let toggleListener = null;
function emitToggle(visible) {
onToggle()(visible);
toggleListener == null ? void 0 : toggleListener(visible);
}
function setToggleListener(cb) {
toggleListener = cb;
}
function dock(rb) {
var _a2;
if (!rb || !bodyEl || !((_a2 = rb._pcrRegion) == null ? void 0 : _a2.enterDock)) return;
rb._pcrRegion.enterDock(bodyEl);
dockedNode = rb;
set(hasDock, true);
}
function undock() {
var _a2;
if (dockedNode && ((_a2 = dockedNode._pcrRegion) == null ? void 0 : _a2.exitDock)) {
try {
dockedNode._pcrRegion.exitDock();
} catch (e) {
console.error("[PromptChain] region undock error", e);
}
}
dockedNode = null;
set(hasDock, false);
}
function refreshDock() {
if (!get(isVisible)) return;
if (dockedNode && dockedNode._pcrAlive) return;
if (dockedNode) undock();
const active = getActiveRegionBox()();
if (active) dock(active);
}
function show() {
set(isVisible, true);
if (node().properties) node().properties.pcrRegionPanel = true;
emitToggle(true);
requestAnimationFrame(() => {
if (get(isVisible)) dock(getActiveRegionBox()());
});
}
function hide() {
undock();
set(isVisible, false);
if (node().properties) node().properties.pcrRegionPanel = false;
emitToggle(false);
}
function toggle() {
get(isVisible) ? hide() : show();
}
function getIsVisible() {
return get(isVisible);
}
function getMaxPanelWidth() {
const row = panelEl == null ? void 0 : panelEl.parentElement;
const rowWidth = (row == null ? void 0 : row.offsetWidth) || 0;
if (!rowWidth) return Number.POSITIVE_INFINITY;
return Math.max(MIN_WIDTH, rowWidth - 120);
}
function clampPanelWidth(value) {
return Math.min(Math.max(MIN_WIDTH, value), getMaxPanelWidth());
}
onMount(() => {
var _a2, _b2;
(_a2 = onRegister()) == null ? void 0 : _a2({
toggle,
show,
hide,
getIsVisible,
refreshDock,
setToggleListener,
cleanup
});
if ((_b2 = node().properties) == null ? void 0 : _b2.pcrRegionPanel) requestAnimationFrame(() => show());
requestAnimationFrame(() => {
set(panelWidth, clampPanelWidth(get(panelWidth)), true);
});
});
let dividerAc;
onMount(() => {
dividerAc = new AbortController();
let isDragging = false;
let startX = 0;
let startWidth = 0;
const getScale = () => {
const inFs = !!document.querySelector(".pcr-fs-overlay");
return inFs ? 1 : getCanvasScale();
};
document.addEventListener(
"pointerdown",
(e) => {
if (!dividerEl || e.target !== dividerEl && !dividerEl.contains(e.target)) return;
isDragging = true;
startX = e.clientX;
startWidth = (panelEl == null ? void 0 : panelEl.offsetWidth) || get(panelWidth);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const canvas = document.querySelector("canvas.lgraphcanvas");
if (canvas && typeof e.pointerId === "number") {
try {
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
} catch {
}
}
},
{ capture: true, signal: dividerAc.signal }
);
document.addEventListener(
"pointermove",
(e) => {
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();
const delta = (e.clientX - startX) / getScale();
set(panelWidth, clampPanelWidth(startWidth + delta), true);
},
{ capture: true, signal: dividerAc.signal }
);
document.addEventListener(
"pointerup",
() => {
if (!isDragging) return;
isDragging = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
if (node().properties) node().properties.pcrRegionPanelWidth = get(panelWidth);
},
{ capture: true, signal: dividerAc.signal }
);
});
let closeState = state(
"hidden"
// hidden | dim | full
);
function onPanelMove(e) {
if (!panelEl) return;
const r = panelEl.getBoundingClientRect();
const inTop = e.clientY - r.top < Math.max(32, r.height * 0.1);
const inRight = r.right - e.clientX < Math.max(32, r.width * 0.1);
set(closeState, inTop && inRight ? "full" : "dim", true);
}
function onPanelLeave() {
set(closeState, "hidden");
}
function cleanup() {
dividerAc == null ? void 0 : dividerAc.abort();
undock();
}
onDestroy(() => {
dividerAc == null ? void 0 : dividerAc.abort();
undock();
});
var $$exports = {
setToggleListener,
refreshDock,
show,
hide,
toggle,
getIsVisible
};
var fragment = root$7();
var div = first_child(fragment);
let styles;
@@ -1456,13 +1716,13 @@ function PosePanel($$anchor, $$props) {
var _a2, _b2, _c, _d;
e.preventDefault();
e.stopPropagation();
const poser = dockedNode || getActivePoser()();
const w = (_b2 = (_a2 = poser == null ? void 0 : poser.widgets) == null ? void 0 : _a2.find((x) => x.name === "width")) == null ? void 0 : _b2.value;
const h = (_d = (_c = poser == null ? void 0 : poser.widgets) == null ? void 0 : _c.find((x) => x.name === "height")) == null ? void 0 : _d.value;
const rb = dockedNode || getActiveRegionBox()();
const w = (_b2 = (_a2 = rb == null ? void 0 : rb.widgets) == null ? void 0 : _a2.find((x) => x.name === "width")) == null ? void 0 : _b2.value;
const h = (_d = (_c = rb == null ? void 0 : rb.widgets) == null ? void 0 : _c.find((x) => x.name === "height")) == null ? void 0 : _d.value;
if (!w || !h) return;
const bodyH = (bodyEl == null ? void 0 : bodyEl.offsetHeight) || (panelEl == null ? void 0 : panelEl.offsetHeight) || 300;
set(panelWidth, clampPanelWidth(Math.round(bodyH * (w / h))), true);
if (node().properties) node().properties.pcrPosePanelWidth = get(panelWidth);
if (node().properties) node().properties.pcrRegionPanelWidth = get(panelWidth);
});
append($$anchor, fragment);
return pop($$exports);
@@ -3440,7 +3700,7 @@ delegate([
"keydown",
"change"
]);
var root = from_html(`<!> <div class="pcr-node-content"><div class="pcr-editor-row"><!> <!> <div class="pcr-editor-stack"><!> <!></div> <!></div> <!></div>`, 1);
var root = from_html(`<!> <div class="pcr-node-content"><div class="pcr-editor-row"><!> <!> <!> <div class="pcr-editor-stack"><!> <!></div> <!></div> <!></div>`, 1);
function NodeWidget($$anchor, $$props) {
push($$props, true);
let shared = prop($$props, "shared", 7), onSetMode = prop($$props, "onSetMode", 3, () => {
@@ -3451,8 +3711,9 @@ function NodeWidget($$anchor, $$props) {
}), onToggleImage = prop($$props, "onToggleImage", 3, () => {
}), onToggleAssistant = prop($$props, "onToggleAssistant", 3, () => {
}), onTogglePose = prop($$props, "onTogglePose", 3, () => {
}), onToggleRegion = prop($$props, "onToggleRegion", 3, () => {
}), onOpenFullscreen = prop($$props, "onOpenFullscreen", 3, () => {
}), docDropdownEl = prop($$props, "docDropdownEl", 3, null), modelIndicatorEl = prop($$props, "modelIndicatorEl", 3, null), tagsDropdownEl = prop($$props, "tagsDropdownEl", 3, null), getEditorView = prop($$props, "getEditorView", 3, () => null), onEditorPaneReady = prop($$props, "onEditorPaneReady", 3, null), onOutputPanelRegister = prop($$props, "onOutputPanelRegister", 3, null), onImagePanelRegister = prop($$props, "onImagePanelRegister", 3, null), onPosePanelRegister = prop($$props, "onPosePanelRegister", 3, null), onAIAssistantRegister = prop($$props, "onAIAssistantRegister", 3, null), onFooterRegister = prop($$props, "onFooterRegister", 3, null), getActivePoser = prop($$props, "getActivePoser", 3, () => null), apiURL = prop($$props, "apiURL", 3, (p) => p), fetchApi = prop($$props, "fetchApi", 3, (p, o) => fetch(p, o)), toast = prop($$props, "toast", 3, () => {
}), docDropdownEl = prop($$props, "docDropdownEl", 3, null), modelIndicatorEl = prop($$props, "modelIndicatorEl", 3, null), tagsDropdownEl = prop($$props, "tagsDropdownEl", 3, null), getEditorView = prop($$props, "getEditorView", 3, () => null), onEditorPaneReady = prop($$props, "onEditorPaneReady", 3, null), onOutputPanelRegister = prop($$props, "onOutputPanelRegister", 3, null), onImagePanelRegister = prop($$props, "onImagePanelRegister", 3, null), onPosePanelRegister = prop($$props, "onPosePanelRegister", 3, null), onRegionPanelRegister = prop($$props, "onRegionPanelRegister", 3, null), onAIAssistantRegister = prop($$props, "onAIAssistantRegister", 3, null), onFooterRegister = prop($$props, "onFooterRegister", 3, null), getActivePoser = prop($$props, "getActivePoser", 3, () => null), getActiveRegionBox = prop($$props, "getActiveRegionBox", 3, () => null), apiURL = prop($$props, "apiURL", 3, (p) => p), fetchApi = prop($$props, "fetchApi", 3, (p, o) => fetch(p, o)), toast = prop($$props, "toast", 3, () => {
}), getWorkflowId = prop($$props, "getWorkflowId", 3, () => ""), fetchWorkflowImages = prop($$props, "fetchWorkflowImages", 3, async () => []), fetchWorkflowCount = prop($$props, "fetchWorkflowCount", 3, async () => 0), subscribeHistory = prop($$props, "subscribeHistory", 3, () => () => {
}), invalidateCache = prop($$props, "invalidateCache", 3, () => {
}), openViewer = prop($$props, "openViewer", 3, () => {
@@ -3499,6 +3760,7 @@ function NodeWidget($$anchor, $$props) {
onToggleImage: () => onToggleImage()(),
onToggleAssistant: () => onToggleAssistant()(),
onTogglePose: () => onTogglePose()(),
onToggleRegion: () => onToggleRegion()(),
get onOpenFullscreen() {
return onOpenFullscreen();
},
@@ -3527,7 +3789,25 @@ function NodeWidget($$anchor, $$props) {
}
});
var node_3 = sibling(node_2, 2);
AIAssistant(node_3, {
RegionPanel(node_3, {
get node() {
return $$props.node;
},
get shared() {
return shared();
},
get getActiveRegionBox() {
return getActiveRegionBox();
},
onToggle: (visible) => {
shared().regionPanelVisible = visible;
},
get onRegister() {
return onRegionPanelRegister();
}
});
var node_4 = sibling(node_3, 2);
AIAssistant(node_4, {
get node() {
return $$props.node;
},
@@ -3541,10 +3821,10 @@ function NodeWidget($$anchor, $$props) {
return onAIAssistantRegister();
}
});
var div_2 = sibling(node_3, 2);
var node_4 = child(div_2);
var div_2 = sibling(node_4, 2);
var node_5 = child(div_2);
bind_this(
EditorPane(node_4, {
EditorPane(node_5, {
get node() {
return $$props.node;
},
@@ -3555,8 +3835,8 @@ function NodeWidget($$anchor, $$props) {
($$value) => editorPane = $$value,
() => editorPane
);
var node_5 = sibling(node_4, 2);
OutputPanel(node_5, {
var node_6 = sibling(node_5, 2);
OutputPanel(node_6, {
get node() {
return $$props.node;
},
@@ -3570,8 +3850,8 @@ function NodeWidget($$anchor, $$props) {
return onOutputPanelRegister();
}
});
var node_6 = sibling(div_2, 2);
ImagePanel(node_6, {
var node_7 = sibling(div_2, 2);
ImagePanel(node_7, {
get node() {
return $$props.node;
},
@@ -3585,8 +3865,8 @@ function NodeWidget($$anchor, $$props) {
return onImagePanelRegister();
}
});
var node_7 = sibling(div_1, 2);
Footer(node_7, {
var node_8 = sibling(div_1, 2);
Footer(node_8, {
get node() {
return $$props.node;
},
@@ -3632,7 +3912,9 @@ class SharedState {
__privateAdd(this, _imagePanelVisible, state(false));
__privateAdd(this, _aiAssistantOpen, state(false));
__privateAdd(this, _posePanelVisible, state(false));
__privateAdd(this, _regionPanelVisible, state(false));
__privateAdd(this, _hasPoseStudio, state(false));
__privateAdd(this, _hasRegionBox, state(false));
__privateAdd(this, _compiledOutput, state(""));
__privateAdd(this, _compiledNegOutput, state(""));
__privateAdd(this, _imageUrl, state(null));
@@ -3654,6 +3936,7 @@ class SharedState {
this.imagePanelVisible = !!p.pcrImagePreview;
this.aiAssistantOpen = !!p.pcrAiAssistant;
this.posePanelVisible = !!p.pcrPosePanel;
this.regionPanelVisible = !!p.pcrRegionPanel;
this.compiledOutput = p.pcrCompiledOutput || "";
this.compiledNegOutput = p.pcrCompiledNegOutput || "";
}
@@ -3753,12 +4036,24 @@ class SharedState {
set posePanelVisible(value) {
set(__privateGet(this, _posePanelVisible), value, true);
}
get regionPanelVisible() {
return get(__privateGet(this, _regionPanelVisible));
}
set regionPanelVisible(value) {
set(__privateGet(this, _regionPanelVisible), value, true);
}
get hasPoseStudio() {
return get(__privateGet(this, _hasPoseStudio));
}
set hasPoseStudio(value) {
set(__privateGet(this, _hasPoseStudio), value, true);
}
get hasRegionBox() {
return get(__privateGet(this, _hasRegionBox));
}
set hasRegionBox(value) {
set(__privateGet(this, _hasRegionBox), value, true);
}
get compiledOutput() {
return get(__privateGet(this, _compiledOutput));
}
@@ -3818,7 +4113,9 @@ _outputPanelOpen = new WeakMap();
_imagePanelVisible = new WeakMap();
_aiAssistantOpen = new WeakMap();
_posePanelVisible = new WeakMap();
_regionPanelVisible = new WeakMap();
_hasPoseStudio = new WeakMap();
_hasRegionBox = new WeakMap();
_compiledOutput = new WeakMap();
_compiledNegOutput = new WeakMap();
_imageUrl = new WeakMap();
+45 -15
View File
@@ -433,6 +433,15 @@ function ItemThumbnail($$anchor, $$props) {
if (thumbLoaded) set(showFlash, true);
thumbLoaded = true;
}
function retryThumb(e) {
const img = e.currentTarget;
const n = +img.dataset.pcrRetry || 0;
if (n >= 3) return;
img.dataset.pcrRetry = String(n + 1);
const u = new URL(img.src, location.href);
u.searchParams.set("r", String(n + 1));
img.src = u.toString();
}
function handleDragOver(e) {
if (item().type !== "folder") return;
if (!e.dataTransfer.types.includes("application/x-promptchain-move")) return;
@@ -464,8 +473,8 @@ function ItemThumbnail($$anchor, $$props) {
{
var consequent_1 = ($$anchor2) => {
var fragment = root_1$9();
var img = first_child(fragment);
var node_1 = sibling(img, 2);
var img_1 = first_child(fragment);
var node_1 = sibling(img_1, 2);
{
var consequent = ($$anchor3) => {
var div_2 = root_2$6();
@@ -478,8 +487,8 @@ function ItemThumbnail($$anchor, $$props) {
}
template_effect(
($0) => {
set_attribute(img, "src", $0);
set_attribute(img, "alt", item().name);
set_attribute(img_1, "src", $0);
set_attribute(img_1, "alt", item().name);
},
[
() => apiURL(`/promptchain/browse/preview?scope=${$$props.scope}&path=${encodeURIComponent(item().path)}&thumb=1`)
@@ -489,8 +498,8 @@ function ItemThumbnail($$anchor, $$props) {
};
var consequent_3 = ($$anchor2) => {
var fragment_1 = root_3$7();
var img_1 = first_child(fragment_1);
var node_2 = sibling(img_1, 2);
var img_2 = first_child(fragment_1);
var node_2 = sibling(img_2, 2);
{
var consequent_2 = ($$anchor3) => {
var div_3 = root_4$7();
@@ -503,12 +512,13 @@ function ItemThumbnail($$anchor, $$props) {
}
template_effect(
($0) => {
set_attribute(img_1, "src", $0);
set_attribute(img_1, "alt", item().name);
set_attribute(img_2, "src", $0);
set_attribute(img_2, "alt", item().name);
},
[() => apiURL(`/promptchain/thumb/${item().thumbnailHash}`)]
);
event("load", img_1, onThumbUpdate);
event("load", img_2, onThumbUpdate);
event("error", img_2, retryThumb);
append($$anchor2, fragment_1);
};
var consequent_4 = ($$anchor2) => {
@@ -722,6 +732,15 @@ function GridView($$anchor, $$props) {
next.delete(itemPath);
set(flashingThumbs, next, true);
}
function retryThumb(e) {
const img = e.currentTarget;
const n = +img.dataset.pcrRetry || 0;
if (n >= 3) return;
img.dataset.pcrRetry = String(n + 1);
const u = new URL(img.src, location.href);
u.searchParams.set("r", String(n + 1));
img.src = u.toString();
}
var div = root$2();
var node = child(div);
{
@@ -790,6 +809,7 @@ function GridView($$anchor, $$props) {
[() => thumbSrc(get(item))]
);
event("load", img_2, () => onThumbUpdate(get(item)));
event("error", img_2, retryThumb);
append($$anchor5, fragment_2);
};
var consequent_4 = ($$anchor5) => {
@@ -1000,6 +1020,15 @@ function ListView($$anchor, $$props) {
if (b < 1048576) return (b / 1024).toFixed(1) + " KB";
return (b / 1048576).toFixed(1) + " MB";
}
function retryThumb(e) {
const img = e.currentTarget;
const n = +img.dataset.pcrRetry || 0;
if (n >= 3) return;
img.dataset.pcrRetry = String(n + 1);
const u = new URL(img.src, location.href);
u.searchParams.set("r", String(n + 1));
img.src = u.toString();
}
function fmtDate(ts) {
if (!ts) return "-";
return new Date(ts * 1e3).toLocaleDateString(void 0, { month: "short", day: "numeric" });
@@ -1059,22 +1088,23 @@ function ListView($$anchor, $$props) {
var node_1 = child(span);
{
var consequent = ($$anchor3) => {
var img = root_2$5();
template_effect(($0) => set_attribute(img, "src", $0), [
var img_1 = root_2$5();
template_effect(($0) => set_attribute(img_1, "src", $0), [
() => apiURL(`/promptchain/browse/preview?scope=${$$props.scope}&path=${encodeURIComponent(get(item).path)}&thumb=1`)
]);
append($$anchor3, img);
append($$anchor3, img_1);
};
var consequent_1 = ($$anchor3) => {
var svg = root_3$5();
append($$anchor3, svg);
};
var consequent_2 = ($$anchor3) => {
var img_1 = root_4$5();
template_effect(($0) => set_attribute(img_1, "src", $0), [
var img_2 = root_4$5();
template_effect(($0) => set_attribute(img_2, "src", $0), [
() => apiURL(`/promptchain/thumb/${get(item).thumbnailHash}`)
]);
append($$anchor3, img_1);
event("error", img_2, retryThumb);
append($$anchor3, img_2);
};
var consequent_3 = ($$anchor3) => {
var svg_1 = root_5$5();
File diff suppressed because it is too large Load Diff
+143
View File
@@ -11,11 +11,148 @@ import { prepareInpaint, runInpaint, saveInpaintResult, uploadInputImage, inpain
import { prepareEdit, saveEditedImage } from "./edit-image.js";
import { fetchReposeCaps } from "./repose-from-image.js";
import { runReposeInBackground } from "./repose-background.js";
import { runExtendInBackground } from "./extend-background.js";
import { runSmoothInBackground } from "./smooth-background.js";
import { smoothNodesAvailable } from "./smooth-from-image.js";
import { mountDetachedPoser } from "../pose-studio.js";
import { showNodePackInstallModal } from "./model-bridge.js";
import { loadCodeMirror, createEditor } from "./editor.js";
import { activateTagAutocomplete } from "./autocomplete.js";
// "Extend video": which I2V template to continue with. A continuation is always
// image->video, so it uses an I2V recipe regardless of how the source was made.
// Pick the family whose models are installed (GGUF vs safetensors); the quant
// resolver in buildExtendGraph then maps the template's baked variant to the
// exact file on disk.
async function pickI2VTemplate() {
try {
const res = await api.fetchApi("/promptchain/models/list");
const models = res.ok ? ((await res.json())?.models || []) : [];
const names = models.map((m) => (m.filename || "").toLowerCase());
if (names.some((n) => n.endsWith(".gguf") && n.includes("i2v") && (n.includes("a14b") || n.includes("wan2.2")))) return "wan22-i2v-gguf";
if (names.some((n) => /wan2\.2_i2v_.*(fp8_scaled|fp16)\.safetensors/.test(n))) return "wan22-i2v";
} catch { /* fall through to default */ }
return "wan22-i2v-gguf"; // the resolver remaps to whatever quant is actually installed
}
async function fetchTemplateJson(id) {
try {
const res = await api.fetchApi(`/promptchain/templates/${encodeURIComponent(id)}`);
if (!res.ok) return null;
const data = await res.json();
return data?.template || data;
} catch { return null; }
}
// Lightning (4-step) renders through the "-fast" template, which bakes the
// LightX2V distill LoRAs. Picking it without those files on disk would build an
// empty LoraLoaderModelOnly and render garbage with no error (the same failure
// that rejected sf-video-test). Gate the option on the LoRAs actually being
// installed. Fail-open on any probe error — server-side validation still
// catches a genuinely missing file.
async function lightningLorasInstalled(fastTemplate) {
try {
if (!fastTemplate?.nodes) return true;
const infoRes = await api.fetchApi("/object_info/LoraLoaderModelOnly");
if (!infoRes.ok) return true;
const info = await infoRes.json();
const installed = info?.LoraLoaderModelOnly?.input?.required?.lora_name?.[0] || [];
const installedSet = new Set(installed.map((n) => String(n).toLowerCase()));
const needed = fastTemplate.nodes
.filter((n) => (n.type || n.comfyClass) === "LoraLoaderModelOnly")
.map((n) => String(n.widgets_values?.[0] || "").toLowerCase())
.filter(Boolean);
if (!needed.length) return true;
return needed.every((f) => installedSet.has(f));
} catch { return true; }
}
// Per-recipe sampler defaults read from the SELECTED template's baked widgets —
// NOT a config, because config presets only describe the normal recipe and are
// wrong for the 4-step lightning one. KSamplerAdvanced widget order:
// add_noise, noise_seed, control_after_generate, steps, cfg, sampler_name,
// scheduler, start_at_step, end_at_step, return_with_leftover_noise.
function extractRecipeDefaults(template) {
if (!template?.nodes) return null;
const find = (t) => template.nodes.find((n) => (n.type || n.comfyClass) === t);
const all = (t) => template.nodes.filter((n) => (n.type || n.comfyClass) === t);
const ks = all("KSamplerAdvanced");
const high = ks.find((n) => (n.widgets_values || [])[0] === "enable") || ks[0];
const hv = high?.widgets_values || [];
const ms = find("ModelSamplingSD3");
return {
steps: Number(hv[3]) || 20,
cfg: hv[4] != null ? Number(hv[4]) : 3.5,
sampler: hv[5] || "euler",
scheduler: hv[6] || "simple",
shift: Number((ms?.widgets_values || [])[0]) || 5,
};
}
async function prepareExtend(hash) {
if (!hash) return null;
const res = await api.fetchApi(`/promptchain/extend/prepare?hash=${encodeURIComponent(hash)}`);
if (!res.ok) return null;
const d = await res.json();
if (!d?.last_frame) return null;
const q = new URLSearchParams({ filename: d.last_frame, subfolder: "", type: "input" });
const templateId = await pickI2VTemplate();
const [normalTpl, fastTpl] = await Promise.all([
fetchTemplateJson(templateId),
fetchTemplateJson(`${templateId}-fast`),
]);
return {
lastFrameFilename: d.last_frame,
lastFrameUrl: api.apiURL(`/view?${q}`),
sourceRef: d.source_ref || "",
fps: d.fps || 16,
frameCount: d.frame_count || 0,
width: d.width || 0,
height: d.height || 0,
templateId, // vanilla base; confirmExtend appends "-fast" for the Lightning recipe
lightningAvailable: await lightningLorasInstalled(fastTpl),
// Slider seeds: clip dims follow the SOURCE (drops the 640×640 hardcode).
// Length defaults to the source's DURATION in native-16fps frames (snapped to
// Wan's 4n+1) — so a smoothed 161f@32fps clip (5s) asks for ~81, not 161
// (which would be a too-long 10s render). fps default is the i2v native (16).
defaults: {
// Snap dims to /16 (Wan's latent grid) and clamp to a trained ceiling, so a
// foreign import's odd 1080p dims can't crash the patchify reshape.
width: Math.min(1280, Math.round((d.width || 640) / 16) * 16),
height: Math.min(1280, Math.round((d.height || 640) / 16) * 16),
length: (() => {
const dur = (d.frame_count || 81) / (d.fps || 16);
const native = Math.round(dur * 16);
// Ceiling 161 (~10s): length is ONE continuation clip, never the whole
// (possibly long / already-extended) source duration — else it OOMs.
return Math.min(161, Math.max(5, Math.round((native - 1) / 4) * 4 + 1));
})(),
fps: d.fps || 16,
},
recipeDefaults: {
normal: extractRecipeDefaults(normalTpl),
lightning: extractRecipeDefaults(fastTpl),
},
};
}
// "Smooth": offer to install the RIFE pack (Frame-Interpolation) on first use,
// then re-register node defs so RIFE VFI becomes available without a full reload.
function installFrameInterpPack() {
return new Promise((resolve) => {
let settled = false;
const settle = (v) => { if (!settled) { settled = true; resolve(v); } };
showNodePackInstallModal(
"FrameInterpolation",
async () => {
try { const defs = await app.getNodeDefs(); await app.registerNodesFromDefs(defs); } catch {}
settle(true);
},
() => settle(false),
);
});
}
// The reference box must show the prompt that actually made THIS image.
// The embedded prompt is authoritative whenever it still carries $region
// blocks — those never survive grafted re-renders, so their presence means
@@ -165,6 +302,12 @@ export async function openViewer(images, startIndex = 0, workflowId = "", onDele
onReposeCaps: () => fetchReposeCaps(),
onReposeRun: (opts) => runReposeInBackground(opts),
onMountPoser: (el, opts) => mountDetachedPoser(el, opts),
onExtendPrepare: (hash) => prepareExtend(hash),
onExtendRun: (opts) => runExtendInBackground(opts),
onSmoothCheck: () => smoothNodesAvailable(),
onSmoothInstall: () => installFrameInterpPack(),
onSmoothPrepare: (hash) => prepareExtend(hash), // reuse: stages the source video + fps
onSmoothRun: (opts) => runSmoothInBackground(opts),
});
}
+37 -1
View File
@@ -17,6 +17,7 @@ import { applyVueMinWidthAll } from "./lib/sizing.js";
import { attachSlotClickHandlers, attachCanvasClickHandler } from "./lib/slot-dropdown.js";
import { getConnectedInputs, getLinkInfo } from "./lib/slot-utils.js";
import { poseRegistry } from "./lib/pose-registry.js";
import { regionBoxRegistry } from "./lib/region-box-registry.js";
import { updateInputLabels, updateParentLabels, checkWildcardConflict, cleanupOrderChain } from "./lib/order-chain.js";
import { createHighlightExtension } from "./lib/active-label-highlight.js";
import { createWildcardBadgeExtension } from "./lib/wildcard-badge.js";
@@ -126,6 +127,7 @@ function syncShared(node, shared) {
shared.outputPanelOpen = !!node.properties?.pcrOutputPanel;
shared.imagePanelVisible = !!node.properties?.pcrImagePreview;
shared.posePanelVisible = !!node.properties?.pcrPosePanel;
shared.regionPanelVisible = !!node.properties?.pcrRegionPanel;
}
// --- Image preview: execution tracking + graph traversal ---
@@ -139,7 +141,12 @@ let _executingNodeId = null;
let _executionWorkflowId = null;
let _lastPreviewUrl = null;
const OUTPUT_NODE_TYPES = new Set(["SaveImage", "PreviewImage"]);
// SaveVideo/SaveWEBM are terminal outputs too: they emit a PreviewVideo UI
// payload shaped like images ({images:[{filename,subfolder,type}], animated})
// so the executed handler treats them uniformly. Without them here the panel's
// generating-node map never includes the video node, so hideProgress() never
// fires and the preview stays stuck on "Generating…" after a video finishes.
const OUTPUT_NODE_TYPES = new Set(["SaveImage", "PreviewImage", "SaveVideo", "SaveWEBM"]);
const KSAMPLER_TYPES = new Set(["KSampler", "KSamplerAdvanced", "PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale", "PromptChain_IdeogramSampler"]);
const IMAGE_LATENT_TYPES = new Set(["IMAGE", "LATENT"]);
const LOAD_IMAGE_TYPES = new Set(["LoadImage"]);
@@ -1399,6 +1406,13 @@ app.registerExtension({
}
node._pcrPosePanel?.toggle();
};
node._pcrMenubar.toggleRegion = () => {
if (node.properties?.pcrCollapsed) {
handleCollapse(node, false, container);
if (shared) syncShared(node, shared);
}
node._pcrRegionPanel?.toggle();
};
node._pcrMenubar.resetIterate = () => {
if (!node.properties) node.properties = {};
node.properties.pcrIterateIndex = 0;
@@ -1469,6 +1483,8 @@ app.registerExtension({
getCanvasScale: () => app.canvas?.ds?.scale ?? 1,
// the Poser node the pose panel should dock (shared registry, last-interacted)
getActivePoser: () => poseRegistry.getActive(),
// the Region Box node the region panel should dock (shared registry)
getActiveRegionBox: () => regionBoxRegistry.getActive(),
// registration callbacks
onEditorPaneReady: (editorContainer) => {
editorContainer._pcrNode = node;
@@ -1527,6 +1543,9 @@ app.registerExtension({
onPosePanelRegister: (panelApi) => {
node._pcrPosePanel = panelApi;
},
onRegionPanelRegister: (panelApi) => {
node._pcrRegionPanel = panelApi;
},
onAIAssistantRegister: (panelApi) => {
node._pcrAiAssistant = panelApi;
},
@@ -1598,6 +1617,13 @@ app.registerExtension({
}
node._pcrPosePanel?.toggle();
},
onToggleRegion: () => {
if (node.properties?.pcrCollapsed) {
handleCollapse(node, false, container);
if (shared) syncShared(node, shared);
}
node._pcrRegionPanel?.toggle();
},
onOpenFullscreen: () => {
import("./lib/fullscreen-bridge.js")
.then(m => m.showSvelteFullscreen(node))
@@ -1616,6 +1642,14 @@ app.registerExtension({
updatePosePresence();
node._pcrUnsubscribePose = poseRegistry.subscribe(updatePosePresence);
// Same gate for the Region Box panel — driven from its own registry.
const updateRegionPresence = () => {
if (shared) shared.hasRegionBox = regionBoxRegistry.count > 0;
node._pcrRegionPanel?.refreshDock?.();
};
updateRegionPresence();
node._pcrUnsubscribeRegion = regionBoxRegistry.subscribe(updateRegionPresence);
// Restore tag config now that node properties are available
tagsDropdown.restore();
@@ -1698,6 +1732,8 @@ app.registerExtension({
cleanupOrderChain(node.id);
node._pcrUnsubscribePose?.();
node._pcrUnsubscribePose = null;
node._pcrUnsubscribeRegion?.();
node._pcrUnsubscribeRegion = null;
if (svelteInstance && svelteModule) {
svelteModule.destroyNodeWidget(svelteInstance);
}
+103 -17
View File
@@ -10,6 +10,7 @@ import { api } from "../../scripts/api.js";
import { poseRegistry } from "./lib/pose-registry.js";
import { setEditorContent, attachDocChangeListener } from "./lib/editor.js";
import { getLink } from "./lib/slot-utils.js";
import { traceRegionConsumer, ensureConsumerPoseWire, seedRegionBlocks, readRegionBlockBodies } from "./lib/regional-binding.js";
const NODE_TYPE = "PromptChain_PoseStudio";
const MIN_NODE_SIZE = [320, 400];
@@ -21,11 +22,17 @@ console.log("%c[PromptChain PoseStudio] BUILD region-rename-reverse-1 (text $nam
// ── figure/prop clipboard ───────────────────────────────────────────────────
// Copy a selection (person, prop, or band-selected group) and paste it into the
// same viewport or any other 3D Poser — including one in another workflow. The
// payload rides the SYSTEM clipboard (survives workflow switches and browser
// restarts); this module-level copy is the fallback when the clipboard API is
// unavailable or permission-denied (then paste works within the current tab).
// payload rides THREE channels, tried in order on paste: the SYSTEM clipboard
// (crosses browsers/profiles, but its async read is permission- and focus-gated
// and silently times out cross-tab), this module-level copy (same page only),
// and localStorage (shared across every tab/window of this origin and surviving
// reloads, with no permission gate — the reliable bridge between two workflows
// opened in separate tabs, which the clipboard read can't be counted on for).
const CLIP_FORMAT = "promptchain-pose-clip";
const POSE_CLIP_STORE_KEY = "promptchain:poseClip";
let poseClipMemory = null;
function writePoseClipStore(json) { try { localStorage.setItem(POSE_CLIP_STORE_KEY, json); } catch {} }
function readPoseClipStore() { try { return localStorage.getItem(POSE_CLIP_STORE_KEY); } catch { return null; } }
// ── lazy Three.js loader ────────────────────────────────────────────────────
// three.bundle.js is an IIFE that assigns window.PromptChainThree, but only
@@ -1081,6 +1088,25 @@ function ensureCouplePoseWire(node) {
if (poseOut >= 0) node.connect(poseOut, couple, poseIn);
}
// Inject region prompts carried from a cross-scene paste (a character built in
// another workflow brings its $name{} body with it). Held on the node until the
// bound PromptChain editor exists, so a paste made before that editor builds
// isn't lost — re-tried from captureAndUpload on the next capture, never polled.
// Uses the broad consumer trace (AttentionCouple / RegionalConditioning /
// Ideogram), so "region mode" here is purely the destination's wiring.
function flushPendingRegions(node) {
const pend = node._pcrPendingRegions;
if (!pend?.length) return;
// Only seed entities that still exist — a figure deleted before the bound
// editor became available must not leave a ghost $name{} bound to no mask row.
const live = node._pcrPose?.entityNames?.();
const fresh = live ? pend.filter((e) => live.some((n) => n.toLowerCase() === e.name.toLowerCase())) : pend;
// seedRegionBlocks returns true when done OR when the dest isn't region-wired
// at all (nothing to inject into) — either way stop retrying; only a wired
// consumer whose editor isn't built yet keeps the paste pending.
if (seedRegionBlocks(node, fresh)) node._pcrPendingRegions = null;
}
// Content id for a capture set (control map + masks), salted with the node id.
// The salt keeps two posers in one graph from sharing filenames — sharing would
// let one poser's superseded-set delete orphan the other's live reference.
@@ -1174,6 +1200,10 @@ async function captureAndUpload(node) {
ps.uploadedOnce = true;
window.dispatchEvent(new CustomEvent("promptchain:pose-uploaded", { detail: { nodeId: node.id } }));
}
// Body-aware first: drain any region prompts carried in from a cross-scene
// paste (writes $name{ tags }), so the add-only reconcile below sees those
// blocks already present and won't re-seed them empty.
flushPendingRegions(node);
// Regional: keep the bound PromptChain prompt's $name{} blocks in sync
// with the region entities — figures AND named props (add-only). No-op
// unless this Poser feeds an AttentionCouple.
@@ -8434,19 +8464,27 @@ async function mountViewport(node, container) {
}
}
const clip = serializeClip(sel);
// Carry each selected NAMED entity's region prompt ($name{ body }) so a
// character built here keeps its prompt when pasted into another scene.
const selNames = sel.map((s) => (s.kind === "figure" ? s.o.customName : s.o.userData?.customName)).filter(Boolean);
const regions = readRegionBlockBodies(node, selNames);
if (Object.keys(regions).length) clip.regions = regions;
const json = JSON.stringify(clip);
poseClipMemory = json;
writePoseClipStore(json); // cross-tab/-reload bridge, no clipboard permission needed
let system = false;
// Bounded: in some contexts the clipboard promise never settles (no
// permission UI to resolve it) — cut/copy must not wedge behind it; the
// tab-local fallback is already written above.
// tab-local + localStorage fallbacks are already written above.
try {
await Promise.race([
navigator.clipboard.writeText(json).then(() => { system = true; }),
new Promise((res) => setTimeout(res, 1500)),
]);
} catch {} // http-non-localhost / permission denied → tab-local fallback
showClipToast(`copied ${clipCountLabel(clip)}${system ? "" : " (this browser tab only)"}`);
} catch {} // http-non-localhost / permission denied → localStorage bridge still carries it
// System clipboard reaches other browsers/profiles; without it the
// localStorage bridge still reaches every tab/window of THIS browser.
showClipToast(`copied ${clipCountLabel(clip)}${system ? "" : " (this browser only)"}`);
};
const parseClip = (text) => {
if (!text) return null;
@@ -8463,11 +8501,22 @@ async function mountViewport(node, container) {
new Promise((res) => setTimeout(() => res(null), 1500)), // never-settling read → tab-local fallback
]);
} catch {}
const clip = parseClip(sysText) || parseClip(poseClipMemory);
const clip = parseClip(sysText) || parseClip(poseClipMemory) || parseClip(readPoseClipStore());
if (!clip) { showClipToast("nothing to paste — copy a person or prop first (right-click → copy)", true); return; }
if (!node._pcrAlive) return;
const data = JSON.parse(JSON.stringify(clip)); // position nudges below must not mutate the stored clipboard
// groupId is per-node (groupIdSeq starts at 1 in every Poser), so a pasted
// "g1" would silently fuse with an unrelated "g1" already in this scene.
// Remap each distinct clip group to a fresh destination-unique id before
// rebuild — buildPropFrom then restores the already-fresh id verbatim.
const groupRemap = new Map();
for (const p of data.props) {
if (typeof p.groupId !== "string" || !p.groupId) continue;
if (!groupRemap.has(p.groupId)) groupRemap.set(p.groupId, "g" + (groupIdSeq++));
p.groupId = groupRemap.get(p.groupId);
}
// Nudge the whole group sideways onto free ground — pasting in place would
// stack the duplicate exactly inside the original. Relative arrangement is
// preserved (one shared dx); a paste into open space (other workflow) lands
@@ -8478,17 +8527,45 @@ async function mountViewport(node, container) {
const spots = [...data.figures.map((fd) => fd.root || [0, 0, 0]), ...data.props.map((p) => p.pos || [0, 0, 0])];
let dx = 0;
while (dx < 6 && spots.some(([x, , z]) => taken(x + dx, z))) dx += 0.5;
// Custom names must stay unique across ALL region entities (figures + named
// props): a colliding pasted name (e.g. duplicating "alice" or "sword" in
// the same scene) falls back to default/unnamed — two masks under one name
// would bind ambiguously.
// A custom name already live in this scene belongs to a DIFFERENT character
// (two people independently built as "maid1" must stay distinct), so suffix
// the paste to the next free "<name>_<n>" instead of dropping it — the
// region and its carried prompt travel intact. Stays within
// validateEntityName's grammar (letters/digits/_, starts with a letter;
// never "mannequinN", which only auto-default figures use — and a default
// figure carries no clip name, so collisions here are always custom↔custom).
const usedNames = new Set(liveEntityNames().map((n) => n.toLowerCase()));
const droppedNames = [];
const clipRegions = data.regions || {}; // { sourceName: bodyText }
const pastedRegions = []; // { name: finalName, body } to inject into the bound prompt
const renamedNames = []; // "old → new" notes for the toast
const carriedBodies = new Set(); // source names whose body is already claimed
const resolvePastedName = (raw) => {
// Defensive: a clip name should already be grammar-valid, but never let an
// odd one through to become a $scope. null → treat as unnamed/default.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw) || /^mannequin\d+$/i.test(raw)) return null;
let nm = raw;
if (usedNames.has(nm.toLowerCase())) {
let n = 2;
while (usedNames.has((raw + "_" + n).toLowerCase())) n++;
nm = raw + "_" + n;
renamedNames.push(raw + " → " + nm);
}
usedNames.add(nm.toLowerCase());
// Carry the source body once per source name — figures are processed before
// props, so the entity that actually owns the $block claims it; a second
// entity sharing the raw name (only via a malformed clip) gets no body.
// typeof guard: a region literally named "__proto__"/"toString" must read as
// empty, not as an inherited Object.prototype member.
const carriedBody = !carriedBodies.has(raw) && typeof clipRegions[raw] === "string" ? clipRegions[raw] : "";
carriedBodies.add(raw);
pastedRegions.push({ name: nm, body: carriedBody });
return nm;
};
for (const fd of data.figures) {
delete fd.uid; // pasted person gets a fresh identity — never collide with the source's
if (typeof fd.name === "string" && fd.name) {
if (usedNames.has(fd.name.toLowerCase())) { droppedNames.push(fd.name); delete fd.name; }
else usedNames.add(fd.name.toLowerCase());
const nm = resolvePastedName(fd.name);
if (nm) fd.name = nm; else delete fd.name;
}
if (Array.isArray(fd.root)) fd.root[0] += dx;
if (fd.footPins) for (const k of Object.keys(fd.footPins)) fd.footPins[k][0] += dx; // world anchors ride along
@@ -8496,8 +8573,8 @@ async function mountViewport(node, container) {
for (const p of data.props) {
delete p.uid; // fresh identity, same rule as figures
if (typeof p.name === "string" && p.name) {
if (usedNames.has(p.name.toLowerCase())) { droppedNames.push(p.name); delete p.name; }
else usedNames.add(p.name.toLowerCase());
const nm = resolvePastedName(p.name);
if (nm) p.name = nm; else delete p.name;
}
if (Array.isArray(p.pos)) p.pos[0] += dx;
}
@@ -8541,8 +8618,17 @@ async function mountViewport(node, container) {
]);
updatePinBtn();
syncBodySliders();
// Carry each pasted character's region + prompt into the bound prompt: stash
// the bodies and inject now (flushed again on the next capture if the bound
// editor isn't built yet), and ensure the POSE_JSON→consumer wire so the
// server can bind each $name to its mask row. No-op when not region-wired.
if (pastedRegions.length) {
node._pcrPendingRegions = (node._pcrPendingRegions || []).concat(pastedRegions);
flushPendingRegions(node);
ensureConsumerPoseWire(node);
}
showClipToast(`pasted ${clipCountLabel(data)}` +
(droppedNames.length ? ` — name${droppedNames.length > 1 ? "s" : ""} ${droppedNames.map((n) => `"${n}"`).join(", ")} taken → default` : ""));
(renamedNames.length ? ` — renamed ${renamedNames.map((n) => `"${n}"`).join(", ")} (already in scene)` : ""));
requestRender();
captureAndUpload(node);
};
+983
View File
@@ -0,0 +1,983 @@
// Region Box — a flat rectangle canvas + region list mounted into the
// PromptChain_RegionBox node body. You draw, name, recolor, reorder and delete
// rectangles on the output frame; the backend node rasterizes each into a region
// mask. Wire MASKS + POSE_JSON into a PromptChain_AttentionCouple (SDXL) or
// PromptChain_RegionalConditioning (Flux) or PromptChain_IdeogramCaption exactly
// like the 3D Poser, and each `$name{}` block in the prompt paints only inside
// its box.
//
// Like the 3D Poser, the canvas is a single relocatable element that can be
// "docked" into a roomy left-side panel (RegionPanel.svelte) — see enterDock /
// exitDock + region-box-registry.js. A SEPARATE extension from the Poser so a JS
// error here can never take down the Poser or the prompt editor. The region-name
// binding lives in the shared ./lib/regional-binding.js (which covers BOTH
// consumer classes).
import { app } from "../../scripts/app.js";
import { getLink } from "./lib/slot-utils.js";
import { regionBoxRegistry } from "./lib/region-box-registry.js";
import {
traceRegionConsumer,
reconcileRegionBlocks,
renameRegionBlock,
ensureConsumerPoseWire,
} from "./lib/regional-binding.js";
const NODE_TYPE = "PromptChain_RegionBox";
const MIN_NODE_SIZE = [340, 360];
const MIN_BOX = 0.02; // ignore accidental micro-drags (fraction of frame)
const HANDLE_VIS = 4; // half-size of a drawn resize handle (px)
const HANDLE_HIT = 12; // px radius to grab a resize handle
const DEADZONE = 4; // px a click must travel before it becomes a move
const SIDEBAR_SHOW_WIDTH = 380; // container width at/above which the region list shows
const PALETTE = ["#5b9dff", "#ff7a7a", "#67d98b", "#f0c651", "#c08bff", "#54d6d6", "#ff9f5b", "#e06fb0"];
console.log("%c[PromptChain RegionBox] BUILD region-box-2 (panel + list redesign) loaded", "color:#7fffb0;font-weight:bold");
let idSeq = 1;
function genId() { return "rb" + (idSeq++); }
// ── tiny widget helpers (local copies; trivial, not the shared binding logic) ──
function setWidgetValue(node, name, value) {
const w = node.widgets?.find((x) => x.name === name);
if (w) w.value = value;
}
function widgetValue(node, name) {
return node.widgets?.find((x) => x.name === name)?.value;
}
function hideWidget(node, name) {
const w = node.widgets?.find((x) => x.name === name);
if (!w) return;
w.type = "hidden";
w.computeSize = () => [0, -4];
}
// ── one-time stylesheet for the region list (hover states need real CSS) ──────
function installRegionStyles() {
if (document.getElementById("pcr-region-box-styles")) return;
const s = document.createElement("style");
s.id = "pcr-region-box-styles";
s.textContent = `
.pcr-region-box { display:flex; flex-direction:row; }
.pcr-rb-sidebar {
display:flex; flex-direction:column; width:150px; flex-shrink:0;
background:#15151b; border-right:1px solid #2c2c36; min-width:0; overflow:hidden;
}
.pcr-rb-head {
display:flex; align-items:center; justify-content:space-between;
padding:6px 8px; font:600 11px/1 sans-serif; color:#9aa0ad;
letter-spacing:.04em; text-transform:uppercase; border-bottom:1px solid #2c2c36;
}
.pcr-rb-addbtn {
background:#2a2f3a; color:#cfe3ff; border:1px solid #3a4150; border-radius:4px;
font:600 11px sans-serif; padding:2px 7px; cursor:pointer; white-space:nowrap;
}
.pcr-rb-addbtn:hover { background:#34506e; border-color:#4a6a90; }
.pcr-rb-list { flex:1; overflow-y:auto; overflow-x:hidden; padding:4px; }
.pcr-rb-empty { color:#6a6a78; font:italic 11px/1.4 sans-serif; padding:10px 8px; text-align:center; }
.pcr-rb-row {
display:flex; align-items:center; gap:5px; padding:4px 5px; border-radius:4px;
cursor:pointer; margin-bottom:2px;
}
.pcr-rb-row:hover { background:#20212a; }
.pcr-rb-row.active { background:#2c3242; outline:1px solid #4a6a90; }
.pcr-rb-swatch {
width:13px; height:13px; border-radius:3px; flex-shrink:0; cursor:pointer;
border:1px solid rgba(255,255,255,.25);
}
.pcr-rb-name {
flex:1; min-width:0; color:#dfe3ea; font:12px sans-serif;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.pcr-rb-nameinput {
flex:1; min-width:0; background:#1a1a22; color:#cfe3ff;
border:1px solid #5b9dff; border-radius:3px; padding:1px 4px; font:12px sans-serif; outline:none;
}
.pcr-rb-iconbtn {
background:transparent; border:none; color:#7c8190; cursor:pointer;
font:12px/1 sans-serif; padding:1px 3px; border-radius:3px; flex-shrink:0;
}
.pcr-rb-iconbtn:hover { color:#fff; background:#3a4150; }
.pcr-rb-iconbtn:disabled { opacity:.25; cursor:default; background:transparent; }
.pcr-rb-canvas-wrap { position:relative; flex:1; min-width:0; }
`;
document.head.appendChild(s);
}
// ── latent-resolution awareness (fork of the Poser's, scoped to RegionBox) ────
// The canvas letterboxes to the generation aspect, so the box node mirrors the
// downstream latent's width/height. Forked rather than reused because the
// Poser's helpers self-skip on its own node type and re-sync via the pose
// registry — both must point at RegionBox here instead.
const regionBoxNodes = new Set();
const SAMPLER_TYPES = new Set([
"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced",
"PromptChain_RegionalDetailer", "UltimateSDUpscale",
]);
const LATENT_RES_TYPES = new Set([
"EmptyLatentImage", "EmptySD3LatentImage", "EmptyHunyuanLatentVideo",
"SDXLEmptyLatentSizePicker", "EmptyImage", "EmptyMochiLatentVideo", "EmptyLTXVLatentVideo",
]);
function findDownstreamSampler(start, graph) {
const seen = new Set();
const queue = [start];
while (queue.length) {
const n = queue.shift();
if (seen.has(n.id)) continue;
seen.add(n.id);
for (const out of n.outputs || []) {
for (const lid of out.links || []) {
const l = getLink(graph, lid);
if (!l) continue;
const t = graph.getNodeById(l.target_id);
if (!t) continue;
if (SAMPLER_TYPES.has(t.comfyClass || t.type)) return t;
queue.push(t);
}
}
}
return null;
}
function findResUpstream(start, graph) {
const seen = new Set();
const queue = [start];
let fallback = null;
while (queue.length) {
const n = queue.shift();
if (seen.has(n.id)) continue;
seen.add(n.id);
// Skip our OWN node type so a box never adopts its own width/height as the
// latent source (the Poser's copy skips PromptChain_PoseStudio here).
if ((n.comfyClass || n.type) !== NODE_TYPE) {
const w = n.widgets?.find((x) => x.name === "width");
const h = n.widgets?.find((x) => x.name === "height");
if (w && h && typeof w.value === "number" && typeof h.value === "number") {
const res = { width: Math.round(w.value), height: Math.round(h.value), node: n };
if (LATENT_RES_TYPES.has(n.comfyClass || n.type)) return res;
if (!fallback) fallback = res;
}
}
for (const inp of n.inputs || []) {
if (inp.link == null) continue;
const l = getLink(graph, inp.link);
const s = l && graph.getNodeById(l.origin_id);
if (s) queue.push(s);
}
}
return fallback;
}
function findLatentResolution(node) {
const graph = app.graph;
if (!graph) return null;
const sampler = findDownstreamSampler(node, graph);
if (!sampler) return null;
const latentInput = (sampler.inputs || []).find((i) => /latent|samples/i.test(i.name) && i.link != null);
let start = sampler;
if (latentInput) {
const l = getLink(graph, latentInput.link);
const src = l && graph.getNodeById(l.origin_id);
if (src) start = src;
}
return findResUpstream(start, graph);
}
// Editing the latent's width/height pushes the new resolution into every live
// box node — event-driven, no polling. Distinct hook flag from the Poser's so
// both can hook the same latent node independently.
function hookLatentResWidgets(resNode) {
for (const name of ["width", "height"]) {
const w = resNode.widgets?.find((x) => x.name === name);
if (!w || w._pcrRegionResHooked) continue;
w._pcrRegionResHooked = true;
const orig = w.callback;
w.callback = function (...args) {
const r = orig?.apply(this, args);
requestAnimationFrame(() => {
for (const n of regionBoxNodes) if (n._pcrAlive) syncToLatent(n);
});
return r;
};
}
}
function syncToLatent(node) {
const res = findLatentResolution(node);
if (!res) return;
if (res.node) hookLatentResWidgets(res.node);
const wW = node.widgets?.find((w) => w.name === "width");
const hW = node.widgets?.find((w) => w.name === "height");
if (!wW || !hW) return;
if (wW.value === res.width && hW.value === res.height) return;
wW.value = res.width;
hW.value = res.height;
node._pcrRedraw?.(); // re-letterbox to the new aspect
node.setDirtyCanvas?.(true, true);
}
// ── canvas ────────────────────────────────────────────────────────────────
function setupRegionBox(node) {
node._pcrAlive = true;
installRegionStyles();
regionBoxNodes.add(node);
hideWidget(node, "box_state");
let boxes = []; // [{id,name,x,y,w,h,color?}] normalized 0-1, top-left origin
let activeId = null; // id of the selected box, or null
const container = document.createElement("div");
container.className = "pcr-region-box";
container.tabIndex = 0; // focusable so Delete only fires when the canvas has focus
container.style.cssText =
"position:relative;width:100%;height:100%;min-height:120px;outline:none;" +
"background:#0d0d11;border-radius:6px;overflow:hidden;touch-action:none;";
// Region list sidebar (shown only when the surface is wide enough).
const sidebar = document.createElement("div");
sidebar.className = "pcr-rb-sidebar";
const head = document.createElement("div");
head.className = "pcr-rb-head";
const headTitle = document.createElement("span");
headTitle.textContent = "Regions";
const addBtn = document.createElement("button");
addBtn.className = "pcr-rb-addbtn";
addBtn.textContent = "+ Add";
addBtn.title = "Add a region in the centre of the frame";
head.appendChild(headTitle);
head.appendChild(addBtn);
const syncBtn = document.createElement("button");
syncBtn.className = "pcr-rb-addbtn";
syncBtn.textContent = "⟲";
syncBtn.title = "Pull $name{} regions from the prompt into boxes";
syncBtn.addEventListener("click", (e) => { e.stopPropagation(); syncFromPrompt(); });
head.appendChild(syncBtn);
const list = document.createElement("div");
list.className = "pcr-rb-list";
sidebar.appendChild(head);
sidebar.appendChild(list);
container.appendChild(sidebar);
const canvasWrap = document.createElement("div");
canvasWrap.className = "pcr-rb-canvas-wrap";
canvasWrap.style.cursor = "crosshair";
container.appendChild(canvasWrap);
const canvas = document.createElement("canvas");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;";
canvasWrap.appendChild(canvas);
const hint = document.createElement("div");
hint.textContent = "drag empty frame to add · drag a box or its $label to move · right-click to rename / delete";
hint.style.cssText =
"position:absolute;left:8px;bottom:6px;right:8px;pointer-events:none;z-index:2;" +
"font:11px/1.3 sans-serif;color:#8a8a98;text-shadow:0 1px 2px #000;";
canvasWrap.appendChild(hint);
node.addDOMWidget("region_box_view", "region_box_view", container, { serialize: false });
// Track this Region Box for the panel: register existence (gates the menubar
// button) + mark last-touched so the panel docks whichever node you last used.
regionBoxRegistry.add(node);
// Capture phase so it fires even though the canvas's own onDown stops the
// event from bubbling back up to the container.
container.addEventListener("pointerdown", () => regionBoxRegistry.touch(node), true);
// Keep sidebar/list interactions from reaching LiteGraph (node drag/select).
// The canvas isolates itself in onDown; this covers the list controls. Bubble
// phase so inner handlers still run first.
for (const ev of ["pointerdown", "click", "dblclick", "wheel"]) {
container.addEventListener(ev, (e) => e.stopPropagation());
}
// Inline rename input on the canvas. window.prompt() is disabled in the
// ComfyUI desktop (Electron) build — it silently returns null — so a prompt-
// based rename never commits. Overlay <input> like the 3D Poser instead.
const renameInput = document.createElement("input");
renameInput.type = "text";
renameInput.maxLength = 24;
renameInput.spellcheck = false;
renameInput.style.cssText =
"position:absolute;z-index:7;display:none;background:#1a1a22;color:#cfe3ff;" +
"border:1px solid #5b9dff;border-radius:4px;padding:2px 6px;" +
"font:12px sans-serif;outline:none;";
renameInput.addEventListener("pointerdown", (e) => e.stopPropagation());
canvasWrap.appendChild(renameInput);
let renameId = null;
let listRenameCommit = null; // commit fn for an in-place region-list rename, if open
const rowEls = new Map(); // id -> { row, nameEl } so a rename can re-find its live row
const closeRename = () => { renameId = null; renameInput.style.display = "none"; };
const commitCanvasRename = () => {
const id = renameId;
if (id == null) return;
const value = renameInput.value;
closeRename();
applyRename(id, value);
};
renameInput.addEventListener("keydown", (e) => {
e.stopPropagation(); // graph hotkeys must not fire while typing
if (e.key === "Enter") commitCanvasRename();
else if (e.key === "Escape") closeRename();
});
renameInput.addEventListener("blur", commitCanvasRename);
// ── helpers ──
function sanitize(name) {
return String(name || "").replace(/[^\w]/g, "");
}
function uniqueName() {
let n = 1;
const have = new Set(boxes.map((b) => b.name));
while (have.has("region" + n)) n++;
return "region" + n;
}
function indexOfId(id) { return boxes.findIndex((b) => b.id === id); }
function activeIndex() { return indexOfId(activeId); }
function boxColor(b, i) { return b.color || PALETTE[i % PALETTE.length]; }
// letterbox the output aspect inside the canvas's own CSS pixel box
function frame() {
const cw = canvas.clientWidth || 1;
const ch = canvas.clientHeight || 1;
const ow = Math.max(1, widgetValue(node, "width") || 832);
const oh = Math.max(1, widgetValue(node, "height") || 1216);
const ar = ow / oh, car = cw / ch;
let fw, fh;
if (car > ar) { fh = ch; fw = ch * ar; } else { fw = cw; fh = cw / ar; }
return { fx: (cw - fw) / 2, fy: (ch - fh) / 2, fw, fh, cw, ch };
}
const toPx = (b, f) => ({ x: f.fx + b.x * f.fw, y: f.fy + b.y * f.fh, w: b.w * f.fw, h: b.h * f.fh });
// The drawn `$name` chip above a box — kept identical to draw() so it's a
// grab/select/rename target too (clicking the label === clicking the box).
function labelRect(b, f) {
const r = toPx(b, f);
const ctx = canvas.getContext("2d");
ctx.font = "12px sans-serif";
const tw = ctx.measureText("$" + (b.name || "region")).width;
return { x: r.x, y: Math.max(f.fy, r.y - 16), w: tw + 8, h: 16 };
}
// 8 resize handles for a px rect, keyed by which edges they move
function handles(r) {
return [
{ id: "nw", x: r.x, y: r.y }, { id: "n", x: r.x + r.w / 2, y: r.y }, { id: "ne", x: r.x + r.w, y: r.y },
{ id: "e", x: r.x + r.w, y: r.y + r.h / 2 }, { id: "se", x: r.x + r.w, y: r.y + r.h },
{ id: "s", x: r.x + r.w / 2, y: r.y + r.h }, { id: "sw", x: r.x, y: r.y + r.h }, { id: "w", x: r.x, y: r.y + r.h / 2 },
];
}
function cursorForHandle(id) {
if (id === "nw" || id === "se") return "nwse-resize";
if (id === "ne" || id === "sw") return "nesw-resize";
if (id === "n" || id === "s") return "ns-resize";
return "ew-resize";
}
function draw() {
const dpr = window.devicePixelRatio || 1;
const f = frame();
if (canvas.width !== Math.round(f.cw * dpr) || canvas.height !== Math.round(f.ch * dpr)) {
canvas.width = Math.round(f.cw * dpr);
canvas.height = Math.round(f.ch * dpr);
}
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, f.cw, f.ch);
// dead (non-drawable) area vs the drawable "paper" frame — clear figure/ground
ctx.fillStyle = "#0d0d11";
ctx.fillRect(0, 0, f.cw, f.ch);
ctx.fillStyle = "#1b1b22";
ctx.fillRect(f.fx, f.fy, f.fw, f.fh);
// rule-of-thirds guides (subtle composition aid)
ctx.strokeStyle = "rgba(255,255,255,0.05)";
ctx.lineWidth = 1;
for (let k = 1; k <= 2; k++) {
const gx = Math.round(f.fx + (f.fw * k) / 3) + 0.5;
const gy = Math.round(f.fy + (f.fh * k) / 3) + 0.5;
ctx.beginPath(); ctx.moveTo(gx, f.fy); ctx.lineTo(gx, f.fy + f.fh); ctx.stroke();
ctx.beginPath(); ctx.moveTo(f.fx, gy); ctx.lineTo(f.fx + f.fw, gy); ctx.stroke();
}
// bright drawable-frame border
ctx.strokeStyle = "#5a5a6e";
ctx.lineWidth = 2;
ctx.strokeRect(f.fx + 1, f.fy + 1, f.fw - 2, f.fh - 2);
const aIdx = activeIndex();
boxes.forEach((b, i) => {
const r = toPx(b, f);
const color = boxColor(b, i);
ctx.fillStyle = color + "2b"; // ~17% alpha fill
ctx.fillRect(r.x, r.y, r.w, r.h);
ctx.strokeStyle = color;
ctx.lineWidth = i === aIdx ? 2.5 : 1.5;
ctx.strokeRect(r.x, r.y, r.w, r.h);
const label = "$" + (b.name || "region");
ctx.font = "12px sans-serif";
const tw = ctx.measureText(label).width;
const ly = Math.max(f.fy, r.y - 16);
ctx.fillStyle = color;
ctx.fillRect(r.x, ly, tw + 8, 16);
ctx.fillStyle = "#101015";
ctx.fillText(label, r.x + 4, ly + 12);
if (i === aIdx) {
for (const hd of handles(r)) {
ctx.fillStyle = "#fff";
ctx.fillRect(hd.x - HANDLE_VIS, hd.y - HANDLE_VIS, HANDLE_VIS * 2, HANDLE_VIS * 2);
ctx.fillStyle = color;
ctx.fillRect(hd.x - HANDLE_VIS + 1, hd.y - HANDLE_VIS + 1, HANDLE_VIS * 2 - 2, HANDLE_VIS * 2 - 2);
}
}
});
}
node._pcrRedraw = draw;
// ── region list ──
// Commit any open in-place rename before its row gets torn out / replaced.
// Null the handle first so the commit's own renderList doesn't recurse here.
function commitListRename() {
if (!listRenameCommit) return;
const c = listRenameCommit;
listRenameCommit = null;
c();
}
function renderList() {
commitListRename();
rowEls.clear();
list.replaceChildren();
if (!boxes.length) {
const empty = document.createElement("div");
empty.className = "pcr-rb-empty";
empty.textContent = "No regions yet — drag on the canvas or click + Add.";
list.appendChild(empty);
return;
}
boxes.forEach((b, i) => {
const row = document.createElement("div");
row.className = "pcr-rb-row" + (b.id === activeId ? " active" : "");
const swatch = document.createElement("div");
swatch.className = "pcr-rb-swatch";
swatch.style.background = boxColor(b, i);
swatch.title = "Click to change colour";
swatch.addEventListener("click", (e) => { e.stopPropagation(); recolor(b.id); });
const name = document.createElement("span");
name.className = "pcr-rb-name";
name.textContent = "$" + (b.name || "region");
name.title = "Double-click to rename";
name.addEventListener("dblclick", (e) => { e.stopPropagation(); startListRename(b.id); });
const up = document.createElement("button");
up.className = "pcr-rb-iconbtn"; up.textContent = "▲"; up.title = "Move back";
up.disabled = i === 0;
up.addEventListener("click", (e) => { e.stopPropagation(); reorder(b.id, -1); });
const down = document.createElement("button");
down.className = "pcr-rb-iconbtn"; down.textContent = "▼"; down.title = "Move forward";
down.disabled = i === boxes.length - 1;
down.addEventListener("click", (e) => { e.stopPropagation(); reorder(b.id, 1); });
const del = document.createElement("button");
del.className = "pcr-rb-iconbtn"; del.textContent = "✕"; del.title = "Delete region";
del.addEventListener("click", (e) => { e.stopPropagation(); removeBox(b.id); });
row.addEventListener("click", () => { activeId = b.id; draw(); renderList(); });
row.appendChild(swatch);
row.appendChild(name);
row.appendChild(up);
row.appendChild(down);
row.appendChild(del);
list.appendChild(row);
rowEls.set(b.id, { row, nameEl: name });
});
}
// In-place rename keyed by id (not a DOM ref): committing a prior rename may
// rebuild the list, so we look the live row up fresh from rowEls afterwards.
function startListRename(id) {
if (renameId != null) commitCanvasRename(); // close any open canvas rename
commitListRename(); // and any other open list rename
const i = indexOfId(id);
const ref = rowEls.get(id);
if (i < 0 || !ref) return;
const input = document.createElement("input");
input.className = "pcr-rb-nameinput";
input.maxLength = 24;
input.spellcheck = false;
input.value = boxes[i].name;
let done = false;
const finish = (commit) => {
if (done) return;
done = true;
listRenameCommit = null;
if (commit) applyRename(id, input.value);
else renderList();
};
listRenameCommit = () => finish(true);
input.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") finish(true);
else if (e.key === "Escape") finish(false);
});
input.addEventListener("blur", () => finish(true));
input.addEventListener("click", (e) => e.stopPropagation());
ref.row.replaceChild(input, ref.nameEl);
input.focus();
input.select();
}
// ── mutations (each persists, re-binds where names/order change, redraws) ──
function applyRename(id, raw) {
const i = indexOfId(id);
if (i < 0) return;
const next = sanitize(raw);
if (!next || next === boxes[i].name) { renderList(); return; }
if (boxes.some((b, j) => j !== i && b.name === next)) { renderList(); return; } // keep unique
const old = boxes[i].name;
boxes[i].name = next;
persist();
renameRegionBlock(node, old, next); // carry typed tags to the new name
bind();
draw();
renderList();
}
function recolor(id) {
const i = indexOfId(id);
if (i < 0) return;
const cur = boxes[i].color || PALETTE[i % PALETTE.length];
const ci = PALETTE.indexOf(cur);
boxes[i].color = PALETTE[(ci + 1) % PALETTE.length];
persist();
draw();
renderList();
}
function reorder(id, dir) {
const i = indexOfId(id);
if (i < 0) return;
const j = i + dir;
if (j < 0 || j >= boxes.length) return;
const [b] = boxes.splice(i, 1);
boxes.splice(j, 0, b); // array order = mask-row / z order
persist();
draw();
renderList();
}
function removeBox(id) {
const i = indexOfId(id);
if (i < 0) return;
boxes.splice(i, 1);
if (activeId === id) activeId = null;
persist();
draw();
renderList();
}
function addRegion() {
const id = genId();
boxes.push({ id, name: uniqueName(), x: 0.3, y: 0.3, w: 0.4, h: 0.4 });
activeId = id;
persist();
bind();
draw();
renderList();
}
addBtn.addEventListener("click", (e) => { e.stopPropagation(); addRegion(); });
function addRegionAt(x, y) {
const id = genId();
const w = 0.3, h = 0.3;
boxes.push({ id, name: uniqueName(), x: clamp01(x - w / 2), y: clamp01(y - h / 2), w, h });
activeId = id;
persist(); bind(); draw(); renderList();
}
function openCanvasRename(id) {
const i = indexOfId(id);
if (i < 0) return;
commitListRename();
activeId = id; renameId = id;
const f = frame();
const r = toPx(boxes[i], f);
renameInput.value = boxes[i].name;
renameInput.style.left = r.x + "px";
renameInput.style.top = Math.max(f.fy, r.y - 18) + "px";
renameInput.style.width = Math.max(70, Math.min(r.w || 120, 160)) + "px";
renameInput.style.display = "block";
renameInput.focus(); renameInput.select();
draw();
}
// Pull `$name{}` blocks from the bound prompt into boxes (prompt -> node).
// Event-driven: on panel open, on rewire, or via the ⟲ button — never polled.
function syncFromPrompt() {
const { pc } = traceRegionConsumer(node);
if (!pc?._pcrEditor) return;
const text = pc._pcrEditor.state.doc.toString();
const have = new Set(boxes.map((b) => b.name.toLowerCase()));
let added = false;
for (const m of text.matchAll(/\$(\w+)\s*\{/g)) {
const low = m[1].toLowerCase();
if (low === "background" || have.has(low)) continue; // background is a scope, not a box
boxes.push({ id: genId(), name: sanitize(m[1]), x: 0.3, y: 0.3, w: 0.4, h: 0.4 });
have.add(low);
added = true;
}
if (added) { persist(); draw(); renderList(); }
}
// ── right-click context menu (Rename / Recolour / Delete on a box; Add on empty) ──
let ctxMenu = null;
function closeCtxMenu() { if (ctxMenu) { ctxMenu.remove(); ctxMenu = null; } }
function openCtxMenu(clientX, clientY, items) {
closeCtxMenu();
const menu = document.createElement("div");
menu.style.cssText =
"position:fixed;z-index:10000;left:" + clientX + "px;top:" + clientY + "px;" +
"background:#1b1b22;border:1px solid #3a3a46;border-radius:6px;padding:4px;" +
"box-shadow:0 6px 24px rgba(0,0,0,.5);min-width:150px;font:13px sans-serif;";
menu.addEventListener("pointerdown", (e) => e.stopPropagation());
for (const it of items) {
const row = document.createElement("div");
row.textContent = it.label;
row.style.cssText = "padding:6px 10px;border-radius:4px;cursor:pointer;color:" +
(it.danger ? "#ff8a8a" : "#dfe3ea") + ";";
row.addEventListener("mouseenter", () => { row.style.background = "#2c3242"; });
row.addEventListener("mouseleave", () => { row.style.background = "transparent"; });
row.addEventListener("click", (e) => { e.stopPropagation(); closeCtxMenu(); it.action(); });
menu.appendChild(row);
}
document.body.appendChild(menu);
ctxMenu = menu;
setTimeout(() => document.addEventListener("pointerdown", closeCtxMenu, { capture: true, once: true }), 0);
}
function onContextMenu(e) {
e.preventDefault();
e.stopPropagation(); // suppress LiteGraph's node menu
const { px, py, nx, ny, f } = pointerNorm(e);
const i = hitBox(px, py, f);
if (i >= 0) {
const id = boxes[i].id;
activeId = id; draw(); renderList();
openCtxMenu(e.clientX, e.clientY, [
{ label: "Rename", action: () => openCanvasRename(id) },
{ label: "Recolour", action: () => recolor(id) },
{ label: "Delete", danger: true, action: () => removeBox(id) },
]);
} else if (insideFrame(px, py, f)) {
openCtxMenu(e.clientX, e.clientY, [
{ label: "Add region here", action: () => addRegionAt(nx, ny) },
]);
}
}
function load() {
let data = [];
try {
const raw = JSON.parse(widgetValue(node, "box_state") || "[]");
data = Array.isArray(raw) ? raw : (raw.boxes || []);
} catch { data = []; }
boxes = data.filter((b) => b && typeof b.x === "number").map((b) => ({
id: b.id || null,
name: sanitize(b.name) || "",
x: b.x, y: b.y, w: b.w, h: b.h,
...(b.color ? { color: b.color } : {}),
}));
// stable ids: bump the seq past any saved suffix, then fill missing ids
for (const b of boxes) {
const m = b.id && /^rb(\d+)$/.exec(b.id);
if (m) idSeq = Math.max(idSeq, +m[1] + 1);
}
for (const b of boxes) if (!b.id) b.id = genId();
if (activeId != null && indexOfId(activeId) < 0) activeId = null;
draw();
renderList();
}
function persist() {
setWidgetValue(node, "box_state", JSON.stringify({ boxes }));
node.setDirtyCanvas?.(true, true);
}
// Push box names into the bound prompt as $name{} blocks + wire POSE_JSON.
// Called on commit (add/rename), not during a drag, so the editor isn't thrashed.
function bind() {
ensureConsumerPoseWire(node);
reconcileRegionBlocks(node, boxes.map((b) => b.name).filter(Boolean));
}
// ── pointer interaction ──
let drag = null; // {mode:'new'|'move'|'resize', ...}
function pointerNorm(e) {
const rect = canvas.getBoundingClientRect();
const f = frame();
// getBoundingClientRect() is in SCREEN px (scaled by the LiteGraph graph
// zoom), but frame()/draw() work in canvas LAYOUT px (clientWidth). Convert
// screen->layout so the rubber band, hit-testing and cursor line up at ANY
// zoom. Without this, off-100% zoom made drags miss every box (=> "move
// creates a new box"), the rubber band drift, and the wrong hover cursor.
const sx = (canvas.clientWidth || 1) / (rect.width || 1);
const sy = (canvas.clientHeight || 1) / (rect.height || 1);
const px = (e.clientX - rect.left) * sx;
const py = (e.clientY - rect.top) * sy;
return { px, py, f, nx: (px - f.fx) / f.fw, ny: (py - f.fy) / f.fh };
}
const clamp01 = (v) => Math.min(1, Math.max(0, v));
function insideFrame(px, py, f) {
return px >= f.fx && px <= f.fx + f.fw && py >= f.fy && py <= f.fy + f.fh;
}
function hitHandle(px, py, f) {
const i = activeIndex();
if (i < 0) return null;
const r = toPx(boxes[i], f);
for (const hd of handles(r)) {
if (Math.abs(px - hd.x) <= HANDLE_HIT && Math.abs(py - hd.y) <= HANDLE_HIT) return hd.id;
}
return null;
}
function hitBox(px, py, f) {
for (let i = boxes.length - 1; i >= 0; i--) { // topmost first
const r = toPx(boxes[i], f);
if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) return i;
const l = labelRect(boxes[i], f); // the $name label is part of the box
if (px >= l.x && px <= l.x + l.w && py >= l.y && py <= l.y + l.h) return i;
}
return -1;
}
function updateCursor(px, py, f) {
const h = hitHandle(px, py, f);
if (h) { canvasWrap.style.cursor = cursorForHandle(h); return; }
if (hitBox(px, py, f) >= 0) { canvasWrap.style.cursor = "move"; return; }
canvasWrap.style.cursor = insideFrame(px, py, f) ? "crosshair" : "default";
}
function onDown(e) {
if (e.button !== 0) return;
e.stopPropagation(); // keep litegraph from panning/selecting the node
// Release any stale LiteGraph pointer-capture left by a prior node drag —
// otherwise it keeps the pointer and our move/resize never sees pointermove.
const lg = document.querySelector("canvas.lgraphcanvas");
if (lg && typeof e.pointerId === "number") {
try { if (lg.hasPointerCapture(e.pointerId)) lg.releasePointerCapture(e.pointerId); } catch {}
}
container.focus();
const { px, py, nx, ny, f } = pointerNorm(e);
const h = hitHandle(px, py, f);
if (h) {
drag = { mode: "resize", handle: h };
} else {
const i = hitBox(px, py, f);
if (i >= 0) {
// select first; only turn into a move once the pointer crosses the
// dead-zone, so a plain click selects without nudging the box.
activeId = boxes[i].id;
drag = { mode: "move", ox: nx - boxes[i].x, oy: ny - boxes[i].y, downPx: px, downPy: py, moved: false };
renderList();
} else if (insideFrame(px, py, f)) {
const id = genId();
boxes.push({ id, name: "", x: clamp01(nx), y: clamp01(ny), w: 0, h: 0 });
activeId = id;
drag = { mode: "new", ax: clamp01(nx), ay: clamp01(ny) };
} else {
activeId = null; // click in the dead margin clears selection
renderList();
}
}
canvas.setPointerCapture?.(e.pointerId);
draw();
}
function onMove(e) {
const p = pointerNorm(e);
if (!drag) { updateCursor(p.px, p.py, p.f); return; }
e.stopPropagation();
const { px, py, nx, ny } = p;
const i = activeIndex();
if (i < 0) return;
const b = boxes[i];
if (drag.mode === "new") {
b.x = Math.min(drag.ax, clamp01(nx));
b.y = Math.min(drag.ay, clamp01(ny));
b.w = Math.abs(clamp01(nx) - drag.ax);
b.h = Math.abs(clamp01(ny) - drag.ay);
} else if (drag.mode === "move") {
if (!drag.moved) {
if (Math.abs(px - drag.downPx) < DEADZONE && Math.abs(py - drag.downPy) < DEADZONE) return;
drag.moved = true;
}
b.x = clamp01(nx - drag.ox); if (b.x + b.w > 1) b.x = 1 - b.w;
b.y = clamp01(ny - drag.oy); if (b.y + b.h > 1) b.y = 1 - b.h;
} else if (drag.mode === "resize") {
resize(b, drag.handle, clamp01(nx), clamp01(ny));
}
draw();
}
function resize(b, handle, nx, ny) {
let x0 = b.x, y0 = b.y, x1 = b.x + b.w, y1 = b.y + b.h;
if (handle.includes("w")) x0 = nx;
if (handle.includes("e")) x1 = nx;
if (handle.includes("n")) y0 = ny;
if (handle.includes("s")) y1 = ny;
b.x = Math.min(x0, x1); b.w = Math.abs(x1 - x0);
b.y = Math.min(y0, y1); b.h = Math.abs(y1 - y0);
}
function onUp(e) {
if (!drag) return;
e.stopPropagation();
canvas.releasePointerCapture?.(e.pointerId);
const wasNew = drag.mode === "new";
const moved = drag.moved === true;
const resized = drag.mode === "resize";
drag = null;
const i = activeIndex();
const b = i >= 0 ? boxes[i] : null;
if (wasNew && b && (b.w < MIN_BOX || b.h < MIN_BOX)) {
boxes.splice(i, 1); activeId = null; // discard a micro-drag (a click on empty frame)
persist();
} else if (wasNew && b) {
if (!b.name) b.name = uniqueName();
persist();
bind(); // new region: seed its $name{} block + wire POSE_JSON
} else if (moved || resized) {
persist(); // move/resize: geometry changed, names unchanged
}
// a pure select click (move mode, dead-zone never crossed) changes nothing
draw();
renderList();
}
function onDblClick(e) {
e.stopPropagation();
if (renameId != null) commitCanvasRename(); // commit any open canvas rename
const { px, py, f } = pointerNorm(e);
const i = hitBox(px, py, f);
if (i < 0) return;
openCanvasRename(boxes[i].id);
}
function onKey(e) {
if ((e.key === "Delete" || e.key === "Backspace") && activeId != null && renameId == null) {
e.preventDefault();
e.stopPropagation();
removeBox(activeId);
}
}
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
canvas.addEventListener("dblclick", onDblClick);
canvas.addEventListener("contextmenu", onContextMenu);
canvas.addEventListener("wheel", (e) => e.stopPropagation(), { passive: true });
container.addEventListener("keydown", onKey);
// Responsive: show the region list only when there's room; always redraw the
// canvas to its new size. Event-driven via ResizeObserver (no polling).
function applyResponsive() {
sidebar.style.display = container.clientWidth >= SIDEBAR_SHOW_WIDTH ? "flex" : "none";
if (renameId != null) commitCanvasRename(); // commit (not discard) — reflow would float it
draw();
}
const ro = new ResizeObserver(() => applyResponsive());
ro.observe(container);
// ── dock into the Region Box panel (relocate-and-restore, mirrors the Poser) ──
// The !important flow styles beat ComfyUI's per-frame DOM-widget writes (it
// still holds this element's ref and would otherwise re-absolute-position/hide).
let dockActive = false, dockSavedCss = "", dockParent = null, dockNextSibling = null;
const enterDock = (panelBodyEl) => {
if (!panelBodyEl) return;
if (dockActive) exitDock(); // re-dock into a different panel cleanly
dockActive = true;
dockSavedCss = container.style.cssText;
dockParent = container.parentNode;
dockNextSibling = container.nextSibling;
panelBodyEl.appendChild(container);
container.style.cssText =
"position:relative !important;left:0 !important;top:0 !important;" +
"width:100% !important;height:100% !important;transform:none !important;" +
"margin:0 !important;max-width:none !important;max-height:none !important;" +
"display:flex !important;flex-direction:row !important;visibility:visible !important;opacity:1 !important;" +
"background:#0d0d11;border-radius:0;overflow:hidden;touch-action:none;";
applyResponsive();
requestAnimationFrame(() => syncFromPrompt()); // opening the panel pulls in $name{} blocks
};
const exitDock = () => {
if (!dockActive) return;
dockActive = false;
if (dockParent && dockParent.isConnected) {
if (dockNextSibling && dockNextSibling.parentNode === dockParent) dockParent.insertBefore(container, dockNextSibling);
else dockParent.appendChild(container);
} else {
container.remove(); // home slot gone (node removed while docked)
}
dockParent = dockNextSibling = null;
container.style.cssText = dockSavedCss;
applyResponsive();
};
node._pcrRegion = { enterDock, exitDock, container };
// Adopt the downstream latent's resolution; re-sync when this node is rewired.
const origOnConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function (...args) {
origOnConnectionsChange?.apply(this, args);
requestAnimationFrame(() => syncToLatent(node));
};
// Reload boxes after the workflow restores the hidden widget value.
const origOnConfigure = node.onConfigure;
node.onConfigure = function (...args) {
origOnConfigure?.apply(this, args);
load();
};
node.computeSize = (out) => {
if (out) {
const cur = node.size || MIN_NODE_SIZE;
return [Math.max(MIN_NODE_SIZE[0], cur[0]), Math.max(MIN_NODE_SIZE[1], cur[1])];
}
return [...MIN_NODE_SIZE];
};
if (!node.size || node.size[0] < MIN_NODE_SIZE[0] || node.size[1] < MIN_NODE_SIZE[1]) {
node.setSize?.(node.computeSize());
}
const origOnRemoved = node.onRemoved;
node.onRemoved = function () {
origOnRemoved?.call(this);
node._pcrAlive = false;
regionBoxNodes.delete(node);
regionBoxRegistry.remove(node); // un-gate panels; open panels re-dock or show empty
closeCtxMenu();
try { node._pcrRegion?.exitDock?.(); } catch {}
node._pcrRegion = null;
ro.disconnect();
};
load();
requestAnimationFrame(() => { load(); applyResponsive(); syncToLatent(node); regionBoxRegistry.signalReady(node); });
}
app.registerExtension({
name: "PromptChain.RegionBox",
async nodeCreated(node) {
if (node.comfyClass !== NODE_TYPE) return;
setupRegionBox(node);
},
});
+248 -30
View File
@@ -2,36 +2,222 @@ from __future__ import annotations
import json
import logging
import re
from comfy_api.latest import io
logger = logging.getLogger("promptchain.ideogram_caption")
def _envelope(text: str) -> str:
"""Wrap plain text in Ideogram's minimal JSON caption envelope.
def _caption(text: str) -> str:
"""Wrap a plain PromptChain prompt in a COMPLETE Ideogram JSON caption.
Ideogram 4 was trained on structured JSON captions; its docs state the
'Image blocked by safety filter' false-positive rate is HIGHER for non-JSON
prompts. Measured on a benign prompt: plain text blocked 4/5 seeds, this
mechanical envelope (coherent text) blocked 1/5. The remaining ~20% is
cleared by the auto-retry sampler. (A real per-element deconstruction via an
LLM gets to 0/5 — that's the optional upgrade when a local LLM is set up.)
Ideogram 4 was trained on structured JSON captions and its built-in safety
*false-positive* rate is far higher for non-conforming prompts (official:
"false positive rates ... higher for non-json like prompts"). Empirically,
on flagged content (e.g. a swimsuit/rear-pose character): plain text AND a
JSON wrapper with an EMPTY `elements` array both block ~100% across seeds,
while a COMPLETE caption — high_level_description + background + at least one
real `element` — passes ~100% regardless of where the content sits (HLD,
background, or element). So the whole prompt goes into one element's `desc`
under a benign summary: that completeness is what makes the model read it as
a conforming caption rather than falling back to the non-JSON safety path.
"""
t = (text or "").strip()
return json.dumps(
{"high_level_description": t,
"compositional_deconstruction": {"background": t, "elements": []}},
ensure_ascii=False)
# The element bbox must be an INSET subregion: empirically a full-frame
# [0,0,1000,1000] bbox AND omitting bbox both block ~100% on flagged content,
# while an inset region passes (the model reads it as "an object placed in a
# scene" rather than "the whole image IS this object"). Centred, with margins
# on all sides; the desc still drives the full composition.
return json.dumps({
"high_level_description": "A high-quality, detailed image.",
"compositional_deconstruction": {
"background": "a fitting background that suits the subject",
"elements": [{"type": "obj", "bbox": [60, 250, 990, 770], "desc": t}],
},
}, separators=(",", ":"), ensure_ascii=False)
# Ideogram bbox is [y_min, x_min, y_max, x_max] on a 0-1000 grid, top-left origin.
_FULL_FRAME_INSET = [60, 250, 990, 770] # the proven single-element box
def _inset_bbox(bbox) -> list[int]:
"""Clamp a box to 0-1000 and guarantee it is an INSET (never full-frame).
A full-frame element re-trips the safety false-positive (the model reads it
as "the whole image IS this object"). A user-drawn box is normally inset
already; this only rescues an edge-to-edge box."""
try:
y0, x0, y1, x1 = (int(round(v)) for v in bbox)
except (TypeError, ValueError):
return list(_FULL_FRAME_INSET)
y0, y1 = sorted((max(0, min(1000, y0)), max(0, min(1000, y1))))
x0, x1 = sorted((max(0, min(1000, x0)), max(0, min(1000, x1))))
# Degenerate or effectively full-frame -> fall back to the proven inset.
if x1 - x0 < 10 or y1 - y0 < 10 or (x1 - x0 >= 990 and y1 - y0 >= 990):
return list(_FULL_FRAME_INSET)
return [y0, x0, y1, x1]
def _load(obj):
if isinstance(obj, dict):
return obj
if not (obj and isinstance(obj, str) and obj.strip()):
return {}
try:
return json.loads(obj)
except (json.JSONDecodeError, TypeError):
return {}
# A leading quoted literal ('...' or "...") in a `$name{}` region's BODY marks a
# TEXT element — Ideogram renders that exact string (its headline feature). This
# is a property of a real scope (the region body), never of a comment.
_LITERAL_RE = re.compile(r"""^\s*['"](.+?)['"]\s*(.*)$""", re.DOTALL)
def _split_literal(s: str):
"""('LITERAL', rest-as-desc) if s starts with a quoted literal, else (None, s)."""
m = _LITERAL_RE.match(s or "")
if m:
return m.group(1).strip(), m.group(2).strip()
return None, (s or "").strip()
def _mk_obj(e: dict) -> dict:
"""obj element in spec key order: type, bbox?, desc."""
o: dict = {"type": "obj"}
if e.get("bbox") is not None:
o["bbox"] = e["bbox"]
o["desc"] = e.get("desc", "")
return o
def _mk_text(e: dict) -> dict:
"""text element in spec key order: type, bbox?, text, desc."""
o: dict = {"type": "text"}
if e.get("bbox") is not None:
o["bbox"] = e["bbox"]
o["text"] = e.get("text", "")
o["desc"] = e.get("desc") or f'the text "{e.get("text", "")}"'
return o
def _pos_word(bbox) -> str:
"""A rough placement word from a 0-1000 [y0,x0,y1,x1] box, for the HLD."""
if not (isinstance(bbox, (list, tuple)) and len(bbox) == 4):
return ""
xc = (bbox[1] + bbox[3]) / 2.0
yc = (bbox[0] + bbox[2]) / 2.0
h = "left" if xc < 400 else "right" if xc > 600 else ""
v = "top" if yc < 350 else "bottom" if yc > 650 else ""
if h and v:
return f"{v} {h}"
return h or v or "centre"
def _build_caption(prompt_text: str, region_list: list, box_by_name: dict,
background_text: str = "") -> str | None:
"""Build a conforming Ideogram caption from the prompt text + `$name{}` regions.
Comments are NOT parsed — `prompt_text` is the already comment-stripped prompt
(minus the regions). Structure comes ONLY from real scopes: the reserved
`$background{ ... }` region -> the `background` field; each other `$name{}`
region -> one element placed at its drawn box (bbox optional, matched by name);
a region whose body starts with a quoted literal `"..."` -> a `type:"text"`
element (Ideogram renders that exact string). The remaining prompt text ->
high_level_description. A complete caption (HLD + background + >=1 INSET-bbox
element) keeps the safety bypass, so we anchor one element (preferring a
subject) with the proven inset when no drawn box supplied one. Returns None
when there's nothing to build.
"""
raw_obj: list[dict] = []
raw_text: list[dict] = []
for r in region_list:
name = str(r.get("name", "")).lower()
box = box_by_name.get(name)
d = (r.get("text") or "").strip()
if not d:
if box is None:
continue # nothing to place; the HLD covers it
d = name or "subject" # box drawn but no text: keep the placement
bbox = _inset_bbox(box) if box is not None else None
lit, rest = _split_literal(d)
e = {"text": lit, "desc": rest} if lit is not None else {"desc": d}
if bbox is not None:
e["bbox"] = bbox
(raw_text if lit is not None else raw_obj).append(e)
prompt_text = (prompt_text or "").strip()
if raw_obj or raw_text:
# Ideogram leans HARD on the high_level_description; a STYLE-ONLY HLD lets
# it free-compose (e.g. a whole wedding party instead of one woman + one
# man). Weave the subjects + their box positions into the summary so the
# count and placement actually stick.
phrases = []
for e in raw_obj + raw_text:
d = (e.get("desc") or e.get("text") or "").strip()
if not d:
continue
pos = _pos_word(e.get("bbox"))
phrases.append(f"{d} on the {pos}" if pos else d)
summary = ", and ".join(phrases)
if summary and prompt_text:
hld = f"{prompt_text.rstrip(' .')}, showing {summary}."
elif summary:
hld = summary[:1].upper() + summary[1:] + "."
else:
hld = prompt_text or "A high-quality, detailed image."
else:
if not prompt_text:
return None
raw_obj = [{"desc": prompt_text}] # no regions: the prompt is the subject
hld = "A high-quality, detailed image."
# safety: guarantee >=1 element carries an inset bbox (a boxless / full-frame
# caption re-trips the safety false-positive). Prefer anchoring a subject.
if not any("bbox" in e for e in raw_obj + raw_text):
(raw_obj[0] if raw_obj else raw_text[0])["bbox"] = list(_FULL_FRAME_INSET)
elements = [_mk_obj(o) for o in raw_obj] + [_mk_text(t) for t in raw_text]
background = (background_text or "").strip() or "a fitting background that suits the subject"
cap = {
"high_level_description": hld,
"compositional_deconstruction": {
"background": background,
"elements": elements,
},
}
return json.dumps(cap, separators=(",", ":"), ensure_ascii=False)
def _needs_wrapping(s: str) -> bool:
"""A hand-written caption is left alone ONLY if it's valid JSON with a
non-empty elements array — an empty-elements caption is the exact thing that
trips the safety false-positive, so we re-wrap it."""
if not (s.startswith("{") and s.endswith("}")):
return True
try:
obj = json.loads(s)
except json.JSONDecodeError:
return True
els = (obj.get("compositional_deconstruction") or {}).get("elements")
return not (isinstance(els, list) and len(els) > 0)
class IdeogramCaptionNode(io.ComfyNode):
"""Turn a PromptChain prompt into an Ideogram-friendly JSON caption.
"""Turn a PromptChain prompt into a conforming Ideogram JSON caption.
Sits between PromptChain's `positive` output and CLIPTextEncode in the
Ideogram template. JSON-shaped prompts trip Ideogram's stochastic safety
refusal far less often than plain text. If the user already wrote valid JSON,
it's passed through untouched.
Ideogram template. A complete structured caption trips Ideogram's stochastic
safety refusal far less than plain text (the model treats non-conforming
prompts as the high-false-positive path). A hand-written caption that already
has elements is passed through untouched.
Optional regional inputs: wire PromptChain's `regions` output and a
PromptChain_RegionBox `POSE_JSON` to turn each drawn, named box into a caption
element with its own bbox — visual region guidance for Ideogram.
"""
@classmethod
@@ -43,9 +229,18 @@ class IdeogramCaptionNode(io.ComfyNode):
inputs=[
io.String.Input("text", default="", force_input=True,
tooltip="Wire to Prompt Chain's 'positive' output."),
io.Combo.Input("mode", options=["envelope", "off"], default="envelope",
tooltip="envelope = wrap plain text in Ideogram's JSON caption "
"(reduces the model's false safety blocks); off = passthrough."),
io.Combo.Input("mode", options=["caption", "off"], default="caption",
tooltip="caption = wrap the prompt in a complete Ideogram JSON "
"caption (avoids the model's false safety blocks); "
"off = passthrough."),
# Optional regional pair. When both are wired, each $name{} region
# becomes a caption element placed at its drawn box.
io.String.Input("regions", default="", optional=True, force_input=True,
tooltip="Optional: wire Prompt Chain's 'regions' output (4th) "
"to place each $name{} block at its Region Box."),
io.String.Input("pose", default="", optional=True, force_input=True,
tooltip="Optional: wire a Region Box POSE_JSON — carries each "
"region's box (bbox) so the caption places it."),
],
outputs=[
io.String.Output("text"),
@@ -53,15 +248,38 @@ class IdeogramCaptionNode(io.ComfyNode):
)
@classmethod
def execute(cls, text: str = "", mode: str = "envelope") -> io.NodeOutput:
s = (text or "").strip()
if mode == "off" or not s:
def execute(cls, text: str = "", mode: str = "caption",
regions: str = "", pose: str = "") -> io.NodeOutput:
if mode == "off":
return io.NodeOutput(text)
# Already valid JSON (user hand-wrote a caption) — leave it alone.
if s.startswith("{") and s.endswith("}"):
try:
json.loads(s)
return io.NodeOutput(text)
except json.JSONDecodeError:
pass
return io.NodeOutput(_envelope(text))
s = (text or "").strip()
if s and not _needs_wrapping(s):
return io.NodeOutput(text) # already a complete caption
rdata = _load(regions)
region_list = rdata.get("regions") or []
# `global` is the prompt with comments stripped and the $name{} regions
# removed; fall back to the plain positive text when regions aren't wired.
prompt_text = rdata.get("global")
if prompt_text is None:
prompt_text = text
# Reserved `$background{ ... }` scope -> the Ideogram background field;
# every other $name{} region is a subject element.
background_text = ""
subjects = []
for r in region_list:
if str(r.get("name", "")).lower() == "background":
background_text = (r.get("text") or "").strip() or background_text
else:
subjects.append(r)
box_by_name = {}
for e in (_load(pose).get("regionEntities") or []):
if isinstance(e, dict) and e.get("name") and isinstance(e.get("bbox"), (list, tuple)):
box_by_name[str(e["name"]).lower()] = e["bbox"]
# Caption from the prompt text + $name{} region scopes only. Comments are
# never parsed for structure — they are stripped by the compiler.
caption = _build_caption(prompt_text, subjects, box_by_name, background_text)
if caption:
return io.NodeOutput(caption)
if not s:
return io.NodeOutput(text)
return io.NodeOutput(_caption(text))
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import hashlib
import json
import torch
import torch.nn.functional as F
from comfy_api.latest import io
def _parse_boxes(box_state: str) -> list[dict]:
"""Read the canvas's serialized boxes: [{name, x, y, w, h}] normalized 0-1,
top-left origin. Tolerant of a bare list or a {boxes:[...]} wrapper, and
skips malformed entries rather than 500-ing the node."""
try:
data = json.loads(box_state) if (box_state and box_state.strip()) else []
except (json.JSONDecodeError, TypeError):
return []
raw = data.get("boxes", []) if isinstance(data, dict) else data
out = []
if isinstance(raw, list):
for b in raw:
if not isinstance(b, dict):
continue
try:
x, y, w, h = float(b["x"]), float(b["y"]), float(b["w"]), float(b["h"])
except (KeyError, TypeError, ValueError):
continue
out.append({"name": str(b.get("name", "")).strip(), "x": x, "y": y, "w": w, "h": h})
return out
def _feather(mask: torch.Tensor, px: int) -> torch.Tensor:
"""Soft falloff on a [1,H,W] mask via a separable gaussian.
Hard rectangle masks seam on Flux regional conditioning (its mask blend has
no implicit smoothing); a few px of feather removes the visible region edge.
px<=0 keeps the mask hard — SDXL attention-couple tolerates hard edges, and
the consumers' own mask_dilation already grows the region outward.
"""
if px <= 0:
return mask
radius = int(px)
sigma = max(px / 2.0, 0.5)
xs = torch.arange(-radius, radius + 1, dtype=torch.float32)
k = torch.exp(-(xs ** 2) / (2.0 * sigma * sigma))
k = k / k.sum()
m = mask.unsqueeze(0) # [1,1,H,W]
m = F.conv2d(m, k.view(1, 1, -1, 1), padding=(radius, 0))
m = F.conv2d(m, k.view(1, 1, 1, -1), padding=(0, radius))
return m.squeeze(0).clamp(0.0, 1.0)
class RegionBoxNode(io.ComfyNode):
"""Drawn region rectangles -> per-region masks (+ entity order).
A canvas in the node body lets you draw and name rectangles; each becomes a
rectangular mask at the generation resolution. The MASKS + POSE_JSON outputs
drop into PromptChain_AttentionCouple (SDXL) or PromptChain_RegionalConditioning
(Flux) exactly like the 3D Poser's outputs, so each `$name{}` block in the
prompt paints only inside its box. The heavy work is just rasterizing
rectangles, so it happens here from the serialized box list — the canvas does
no rendering.
Mask rows AND regionEntities are built in ONE pass so their order is
identical (the consumers bind a `$name{}` block to a mask row by entity
order/name). fingerprint_inputs hashes the box layout so editing a box
invalidates the sampler cache.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="PromptChain_RegionBox",
display_name="PromptChain Region Boxes",
category="promptchain",
inputs=[
# Serialized boxes (JSON), written by the canvas. The frontend
# hides this widget (the canvas is the editor, not a text field)
# but keeps it serializable so the layout round-trips into the
# workflow and feeds the fingerprint.
io.String.Input("box_state", default="", socketless=True),
# Mask resolution. The canvas letterboxes to this aspect so what
# you draw is the output frame; auto-synced to the generation
# latent (e.g. SDXL 832×1216 portrait).
io.Int.Input("width", default=832, min=64, max=4096, step=8),
io.Int.Input("height", default=1216, min=64, max=4096, step=8),
io.Int.Input("feather", default=0, min=0, max=128, step=1,
tooltip="Soft edge in px. 0 = hard rectangles (fine for SDXL "
"attention couple); raise to ~24-48 to remove region "
"seams on Flux regional conditioning."),
],
outputs=[
# Per-region rectangular masks in box order. Empty layout -> one
# full-canvas mask so a downstream regional node stays valid.
io.Mask.Output("MASKS"),
# Region entities in mask-row order (the binding contract the
# consumers read via their `pose` input).
io.String.Output("POSE_JSON"),
],
)
@classmethod
def execute(cls, box_state: str = "", width: int = 832, height: int = 1216,
feather: int = 0) -> io.NodeOutput:
boxes = _parse_boxes(box_state)
rows: list[torch.Tensor] = []
entities: list[dict] = []
for i, b in enumerate(boxes):
m = torch.zeros((1, height, width), dtype=torch.float32)
x0 = int(round(min(max(b["x"], 0.0), 1.0) * width))
y0 = int(round(min(max(b["y"], 0.0), 1.0) * height))
x1 = int(round(min(max(b["x"] + b["w"], 0.0), 1.0) * width))
y1 = int(round(min(max(b["y"] + b["h"], 0.0), 1.0) * height))
x0, x1 = sorted((x0, x1))
y0, y1 = sorted((y0, y1))
if x1 > x0 and y1 > y0:
m[0, y0:y1, x0:x1] = 1.0
rows.append(_feather(m, feather))
# kind=figure: the binding/orphan logic counts figures and clamps
# unmatched blocks into figure space — a drawn region is a figure-role
# entity (a named prop is a poser-only concept).
# bbox: the same rect in Ideogram's caption form [y_min,x_min,y_max,x_max]
# on a 0-1000 grid (top-left origin). Mask consumers ignore it; the
# Ideogram caption node uses it to place each region's element.
iy0 = int(round(min(max(b["y"], 0.0), 1.0) * 1000))
ix0 = int(round(min(max(b["x"], 0.0), 1.0) * 1000))
iy1 = int(round(min(max(b["y"] + b["h"], 0.0), 1.0) * 1000))
ix1 = int(round(min(max(b["x"] + b["w"], 0.0), 1.0) * 1000))
iy0, iy1 = sorted((iy0, iy1))
ix0, ix1 = sorted((ix0, ix1))
entities.append({
"name": (b["name"] or f"region{i + 1}").lower(),
"kind": "figure",
"bbox": [iy0, ix0, iy1, ix1],
})
if not rows:
masks = torch.ones((1, height, width), dtype=torch.float32)
else:
masks = torch.cat(rows, dim=0)
pose_json = json.dumps({"version": 3, "regionEntities": entities})
return io.NodeOutput(masks, pose_json)
@classmethod
def fingerprint_inputs(cls, box_state: str = "", width: int = 832,
height: int = 1216, feather: int = 0):
m = hashlib.sha256()
m.update((box_state or "").encode("utf-8"))
m.update(f"{width}x{height}x{feather}".encode("utf-8"))
return m.digest().hex()
+4 -2
View File
@@ -529,8 +529,10 @@ class RegionalDetailerNode(io.ComfyNode):
log.info("image %d: %d face(s) detected", b, len(detected))
faces_bound = []
for x1, y1, x2, y2 in detected:
x1 = max(0, x1 - bbox_dilation); y1 = max(0, y1 - bbox_dilation)
x2 = min(img_w, x2 + bbox_dilation); y2 = min(img_h, y2 + bbox_dilation)
x1 = max(0, x1 - bbox_dilation)
y1 = max(0, y1 - bbox_dilation)
x2 = min(img_w, x2 + bbox_dilation)
y2 = min(img_h, y2 + bbox_dilation)
ridx = _match_region((x1, y1, x2, y2), region_masks) if region_masks is not None else -1
faces_bound.append(((x1, y1, x2, y2), ridx))
for (x1, y1, x2, y2), ridx in faces_bound:
+34
View File
@@ -0,0 +1,34 @@
# PromptChain — ComfyUI custom node pack.
# ComfyUI Registry manifest: https://docs.comfy.org/registry/publishing
# `dependencies` is kept in sync with requirements.txt (run `comfy node init`,
# or it is mirrored manually) — requirements.txt is the source of truth.
[project]
name = "comfyui-promptchain"
version = "0.1.1"
description = "Prompt-authoring and image-iteration suite for ComfyUI: structured prompts, 3D posing, model awareness, regional control, and layered editing."
license = { file = "LICENSE" }
requires-python = ">=3.10"
# Mirrors requirements.txt (the source of truth): core load-time dep + optional
# feature deps (PuLID / Face Detailer / AI Selection), declared so they install
# at install time instead of the node running pip at runtime.
dependencies = [
"typing_extensions",
"insightface>=1.0",
"onnxruntime",
"facexlib",
"timm",
"ftfy",
"ultralytics",
"scikit-image",
"pi-heif",
]
[project.urls]
Repository = "https://github.com/mobcat40/ComfyUI-PromptChain"
[tool.comfy]
# Publisher created at registry.comfy.org (immutable; appears in the node URL).
PublisherId = "mobcat40"
DisplayName = "ComfyUI-PromptChain"
Icon = ""
+27
View File
@@ -0,0 +1,27 @@
# PromptChain — dependencies for the ComfyUI Registry.
#
# Two groups:
# 1. Core load-time deps PromptChain's own code imports (ComfyUI ships the
# rest: torch, numpy, Pillow, transformers, einops, av, requests, ...).
# 2. Optional feature deps, declared so they install WITH the pack (Manager /
# pip at install time) instead of the node running pip at runtime — which
# the ComfyUI Registry forbids. These cover PuLID, the Face Detailer, and
# AI Selection. They make a fresh install heavier but keep it compliant.
#
# Torch/numpy/opencv-downgrading pins from the bundled packs (numpy<2,
# ml_dtypes, onnxruntime-gpu, facenet-pytorch) are deliberately NOT mirrored
# here, so `pip install -r requirements.txt` can never break a working ComfyUI.
# The few features that need those (SeedVR2, Flux PuLID) keep their own path.
# --- core ---
typing_extensions
# --- optional feature deps (PuLID · Face Detailer · AI Selection) ---
insightface>=1.0
onnxruntime
facexlib
timm
ftfy
ultralytics
scikit-image
pi-heif
+1 -1
View File
@@ -19,7 +19,7 @@ The output schema is intentionally tiny:
...
Run:
cd C:/comfyui/comfyui/custom_nodes/ComfyUI-PromptChain
cd <ComfyUI>/custom_nodes/ComfyUI-PromptChain
python scripts/natlang_decompose_probe.py
python scripts/natlang_decompose_probe.py <filter>
"""
+1 -1
View File
@@ -11,7 +11,7 @@ any character/outfit/scene tokens baked in. Same prompt works for any
named characters, any outfits, any scene.
Run:
cd C:/comfyui/comfyui/custom_nodes/ComfyUI-PromptChain
cd <ComfyUI>/custom_nodes/ComfyUI-PromptChain
python scripts/natlang_scene_composer.py
"""
from __future__ import annotations
+350
View File
@@ -0,0 +1,350 @@
<script>
// "Extend Video" modal — renders the next clip continuing from the source
// video's last frame (an I2V continuation). The graph is built + queued in the
// background (extend-background.js); the result records as a lineage child.
// Sophisticated controls (recipe, embedded prompt code editor, sampler knobs)
// mirror the upscale/inpaint editor modals; everything is default-preserving —
// the seeds come from the source clip + the chosen recipe's template.
import SettingsSlider from "./model/SettingsSlider.svelte";
let {
open = false,
lastFrameUrl = "",
fps = 16,
frameCount = 0,
lightningAvailable = true, // false = the LightX2V speed LoRAs aren't on disk
defaults = null, // {width,height,length,fps} seeded from the source clip
recipeDefaults = null, // {normal:{steps,cfg,shift,sampler,scheduler}, lightning:{…}}
mountPromptEditor = null, // (container, initialValue, onChange, getModelInfo) => EditorView
modelInfo = null, // {hash, architecture} for the editor's @prompt autocomplete
progress = null,
onRun,
onDone, // () => land on the result + close (the "jump")
onCancel,
} = $props();
// Wan's official negative — folded into the editor as an editable section so an
// empty negative at CFG>1 can't let the clip drift to a static "live wallpaper".
const WAN_NEG = "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走";
const SAMPLERS = ["euler", "euler_ancestral", "dpmpp_2m", "dpmpp_2m_sde", "dpmpp_3m_sde", "uni_pc", "lcm"];
const SCHEDULERS = ["simple", "normal", "beta", "karras", "sgm_uniform", "ddim_uniform"];
let recipe = $state("normal"); // "normal" = vanilla 20-step | "lightning" = 4-step distill
let stitch = $state(true); // join source + continuation into one continuous file
let advancedOpen = $state(false);
// Adjustable params — seeded from `defaults` (clip) + `recipeDefaults` (sampler).
let width = $state(640);
let height = $state(640);
let length = $state(81);
let outFps = $state(16);
let steps = $state(20);
let cfg = $state(3.5);
let shift = $state(5);
let sampler = $state("euler");
let scheduler = $state("simple");
let seed = $state(0);
let seedFixed = $state(false);
let promptText = $state(""); // mirror of the editor (fallback when no editor)
let promptEditorView = null; // CM6 EditorView; authoritative prompt source once mounted
function seedSharedDefaults() {
if (!defaults) return;
width = defaults.width; height = defaults.height;
length = defaults.length;
outFps = 16; // the continuation is always i2v-native 16fps; stitch matches the source automatically
}
function seedRecipeDefaults(r) {
const rd = recipeDefaults?.[r];
if (!rd) return;
steps = rd.steps; cfg = rd.cfg; shift = rd.shift;
sampler = rd.sampler; scheduler = rd.scheduler;
}
let primed = false;
let lastRecipe = "normal";
$effect(() => {
if (open && !primed) {
recipe = "normal"; lastRecipe = "normal"; stitch = true; advancedOpen = false;
seed = 0; seedFixed = false; promptText = "";
seedSharedDefaults(); seedRecipeDefaults("normal");
primed = true;
}
if (!open) primed = false;
});
// Never leave Lightning selected once its LoRAs are known absent.
$effect(() => { if (!lightningAvailable && recipe === "lightning") recipe = "normal"; });
// Re-seed the sampler knobs when the recipe changes (lightning = 4-step / cfg 1).
$effect(() => {
if (recipe !== lastRecipe) { lastRecipe = recipe; seedRecipeDefaults(recipe); }
});
let rd = $derived(recipeDefaults?.[recipe] || {});
let phase = $derived(progress?.phase || null);
let running = $derived(phase === "building" || phase === "queueing" || phase === "running");
let done = $derived(phase === "done");
let pct = $derived(progress?.max ? Math.round((progress.value / progress.max) * 100) : 0);
const editorModelInfo = () => modelInfo || null;
function prefill() { return `\nNegative Prompt: ${WAN_NEG}`; }
// Same use:action pattern as InpaintModal — mounts the CM6 editor while the
// modal is open, mirrors its text into promptText, and tears down on close.
function promptEditor(node) {
let disposed = false, view = null;
(async () => {
if (!mountPromptEditor) return;
const v = await mountPromptEditor(node, prefill(), (text) => { promptText = text; }, editorModelInfo);
if (disposed) { v?.destroy?.(); return; }
view = v; promptEditorView = v;
})().catch((e) => console.error("[Extend] prompt editor mount failed", e));
return { destroy() { disposed = true; view?.destroy?.(); if (promptEditorView === view) promptEditorView = null; } };
}
function currentPromptDoc() {
if (promptEditorView) return promptEditorView.state.doc.toString();
const neg = `Negative Prompt: ${WAN_NEG}`;
return promptText.trim() ? `${promptText.trim()}\n\n${neg}` : neg;
}
function run() {
if (running) return;
onRun?.({
promptDoc: currentPromptDoc(),
stitch, recipe,
width, height, length, outFps,
steps, cfg, shift, sampler, scheduler,
seed: seedFixed ? seed : undefined, // omit unless pinned — keeps template noise behaviour
});
}
// Closing always lands on the latest result if one was rendered.
function close() { if (done) onDone?.(); else onCancel?.(); }
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-ext-overlay" onclick={(e) => { if (e.target === e.currentTarget && !running) close(); }}>
<div class="pcr-ext-modal">
<div class="pcr-ext-title">Extend Video</div>
<div class="pcr-ext-sub">
Renders the next clip continuing from this video's last frame{frameCount ? ` · ${frameCount} frames @ ${fps}fps` : ""}. Saved as a lineage child.
</div>
{#if lastFrameUrl}
<div class="pcr-ext-frame">
<img src={lastFrameUrl} alt="last frame" />
<span>continues from here →</span>
</div>
{/if}
<div class="pcr-ext-label">Speed</div>
<div class="pcr-ext-recipe">
<button
class="pcr-ext-recipe-btn"
class:active={recipe === "normal"}
disabled={running}
onclick={() => { recipe = "normal"; }}
>
<span class="pcr-ext-recipe-name">Normal <span class="pcr-ext-rec-dot" title="Recommended">●</span></span>
<span class="pcr-ext-recipe-sub">20-step · best quality</span>
</button>
<button
class="pcr-ext-recipe-btn"
class:active={recipe === "lightning"}
class:unavail={!lightningAvailable}
disabled={running || !lightningAvailable}
title={lightningAvailable ? "" : "Install the LightX2V speed LoRAs (~1.3 GB) to use Lightning"}
onclick={() => { if (lightningAvailable) recipe = "lightning"; }}
>
<span class="pcr-ext-recipe-name">Lightning</span>
<span class="pcr-ext-recipe-sub">{lightningAvailable ? "4-step · fast draft" : "speed LoRAs not installed"}</span>
</button>
</div>
<label class="pcr-ext-label" for="pcr-ext-prompt">What happens next (motion / action)</label>
{#if mountPromptEditor}
<div class="pcr-ext-prompt pcr-ext-prompt-editor" use:promptEditor></div>
{:else}
<textarea
id="pcr-ext-prompt"
class="pcr-ext-prompt"
bind:value={promptText}
placeholder="e.g. the camera slowly pushes in as she turns her head and smiles"
disabled={running}
></textarea>
{/if}
<label class="pcr-ext-stitch">
<input type="checkbox" bind:checked={stitch} disabled={running} />
Stitch into one continuous video (source + continuation)
</label>
<!-- Advanced: sophisticated knobs, collapsed by default (template-correct
defaults; the green ● marks the recipe's recommended value). -->
<button class="pcr-ext-adv-toggle" onclick={() => advancedOpen = !advancedOpen}>
{advancedOpen ? "▾" : "▸"} Advanced
</button>
{#if advancedOpen}
<div class="pcr-ext-adv">
<div class="pcr-ext-row">
<span class="pcr-ext-row-label">Length <small>{length} frames</small></span>
<SettingsSlider min={5} max={161} step={4} value={length} savedValue={defaults?.length}
onChange={(v) => length = v} />
</div>
<div class="pcr-ext-row">
<span class="pcr-ext-row-label">Output FPS <small>{outFps}</small></span>
<SettingsSlider min={8} max={60} step={1} value={outFps} savedValue={16}
onChange={(v) => outFps = v} />
</div>
<div class="pcr-ext-row">
<span class="pcr-ext-row-label">Steps <small>{steps}</small></span>
<SettingsSlider min={1} max={40} step={1} value={steps} savedValue={rd.steps}
onChange={(v) => steps = v} />
</div>
<div class="pcr-ext-row">
<span class="pcr-ext-row-label">CFG <small>{cfg}</small></span>
<SettingsSlider min={1} max={8} step={0.1} value={cfg} savedValue={rd.cfg}
onChange={(v) => cfg = v} />
</div>
<div class="pcr-ext-row">
<span class="pcr-ext-row-label">Shift <small>{shift}</small></span>
<SettingsSlider min={1} max={12} step={0.5} value={shift} savedValue={rd.shift}
onChange={(v) => shift = v} />
</div>
<div class="pcr-ext-grid2">
<label class="pcr-ext-field">
<span>Sampler</span>
<select bind:value={sampler} disabled={running}>
{#each SAMPLERS as s}<option value={s}>{s}</option>{/each}
</select>
</label>
<label class="pcr-ext-field">
<span>Scheduler</span>
<select bind:value={scheduler} disabled={running}>
{#each SCHEDULERS as s}<option value={s}>{s}</option>{/each}
</select>
</label>
</div>
<div class="pcr-ext-grid2">
<label class="pcr-ext-field">
<span>Seed</span>
<input type="number" bind:value={seed} disabled={running || !seedFixed} />
</label>
<label class="pcr-ext-field pcr-ext-field-check">
<input type="checkbox" bind:checked={seedFixed} disabled={running} />
<span>Fix seed</span>
</label>
</div>
</div>
{/if}
{#if progress}
<div class="pcr-ext-progress">
{#if phase === "error"}
<div class="pcr-ext-err">⚠ {progress.message || "render failed"}</div>
{:else if done}
<div class="pcr-ext-done">✓ Continuation rendered. Adjust and Run Again, or Done to view it.</div>
{#if progress.resultUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="pcr-ext-result" src={progress.resultUrl} controls autoplay loop muted></video>
{/if}
{:else}
<div class="pcr-ext-bar"><div class="pcr-ext-fill" style="width:{pct}%"></div></div>
<div class="pcr-ext-phase">{phase}{progress.max ? ` · ${pct}%` : "…"}</div>
{#if progress.previewUrl}
<img class="pcr-ext-preview" src={progress.previewUrl} alt="preview" />
{/if}
{/if}
</div>
{/if}
<div class="pcr-ext-actions">
{#if running}
<button class="pcr-ext-btn" onclick={() => onCancel?.()}>Cancel</button>
{:else if done}
<button class="pcr-ext-btn" onclick={() => onDone?.()}>Done</button>
<button class="pcr-ext-btn pcr-ext-primary" onclick={run}>Run Again</button>
{:else}
<button class="pcr-ext-btn" onclick={() => onCancel?.()}>Close</button>
<button class="pcr-ext-btn pcr-ext-primary" onclick={run}>Extend</button>
{/if}
</div>
</div>
</div>
{/if}
<style>
.pcr-ext-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center; z-index: 10011;
}
.pcr-ext-modal {
background: #1e1e1e; border: 1px solid #444; border-radius: 8px;
padding: 20px 24px; min-width: 380px; max-width: 480px;
max-height: 88vh; overflow-y: auto;
}
.pcr-ext-title { font-size: 15px; font-weight: 600; color: #eee; margin-bottom: 4px; }
.pcr-ext-sub { font-size: 11px; color: #888; margin-bottom: 14px; line-height: 1.4; }
.pcr-ext-frame { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.pcr-ext-frame img { width: 96px; height: 96px; object-fit: cover; border-radius: 4px; border: 1px solid #333; }
.pcr-ext-frame span { font-size: 11px; color: #777; }
.pcr-ext-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 5px; }
.pcr-ext-recipe { display: flex; gap: 8px; margin-bottom: 14px; }
.pcr-ext-recipe-btn {
flex: 1; display: flex; flex-direction: column; gap: 2px; align-items: flex-start;
padding: 8px 10px; background: #161616; border: 1px solid #444; border-radius: 4px;
color: #ddd; cursor: pointer; text-align: left; transition: border-color 0.15s, background 0.15s;
}
.pcr-ext-recipe-btn:hover:not(:disabled) { border-color: #666; }
.pcr-ext-recipe-btn.active { border-color: #4fc3f7; background: #15242b; }
.pcr-ext-recipe-btn.unavail { opacity: 0.5; cursor: not-allowed; }
.pcr-ext-recipe-btn:disabled { cursor: default; }
.pcr-ext-recipe-name { font-size: 12px; font-weight: 600; }
.pcr-ext-recipe-sub { font-size: 10px; color: #888; }
.pcr-ext-rec-dot { color: #4caf50; font-size: 9px; vertical-align: middle; }
.pcr-ext-prompt {
width: 100%; box-sizing: border-box; min-height: 70px; resize: vertical;
background: #161616; border: 1px solid #444; border-radius: 4px; color: #ddd;
font-size: 12px; padding: 8px; font-family: inherit;
}
.pcr-ext-prompt:focus { outline: none; border-color: #4fc3f7; }
.pcr-ext-prompt-editor { padding: 0; overflow: hidden; min-height: 84px; resize: none; }
.pcr-ext-stitch { display: flex; align-items: center; gap: 7px; font-size: 11px; color: #aaa; margin-top: 10px; cursor: pointer; }
.pcr-ext-adv-toggle {
margin-top: 12px; background: none; border: none; color: #8bd5f5; font-size: 11px;
cursor: pointer; padding: 2px 0;
}
.pcr-ext-adv { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; }
.pcr-ext-row { display: flex; flex-direction: column; gap: 4px; }
.pcr-ext-row-label { font-size: 11px; color: #aaa; display: flex; justify-content: space-between; }
.pcr-ext-row-label small { color: #6cc; }
.pcr-ext-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.pcr-ext-field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #aaa; }
.pcr-ext-field select, .pcr-ext-field input[type="number"] {
background: #161616; border: 1px solid #444; border-radius: 4px; color: #ddd;
font-size: 12px; padding: 5px 6px; font-family: inherit;
}
.pcr-ext-field-check { flex-direction: row; align-items: center; gap: 6px; align-self: end; padding-bottom: 6px; }
.pcr-ext-progress { margin-top: 12px; }
.pcr-ext-bar { height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
.pcr-ext-fill { height: 100%; background: #4fc3f7; border-radius: 3px; transition: width 0.3s; }
.pcr-ext-phase { font-size: 11px; color: #aaa; margin-top: 6px; text-transform: capitalize; }
.pcr-ext-preview, .pcr-ext-result { width: 100%; margin-top: 8px; border-radius: 4px; max-height: 240px; }
.pcr-ext-err { font-size: 12px; color: #f44336; }
.pcr-ext-done { font-size: 12px; color: #4caf50; margin-bottom: 6px; }
.pcr-ext-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.pcr-ext-btn {
padding: 6px 16px; font-size: 12px; border: 1px solid #555; border-radius: 4px;
background: transparent; color: #ccc; cursor: pointer;
}
.pcr-ext-btn:hover { background: rgba(255,255,255,0.08); }
.pcr-ext-btn:disabled { opacity: 0.5; cursor: default; }
.pcr-ext-primary { background: #4fc3f7; color: #111; border-color: #4fc3f7; }
.pcr-ext-primary:hover { background: #39b0e4; }
.pcr-ext-primary:disabled { background: #2a7a9e; }
</style>
+437 -32
View File
@@ -4,6 +4,8 @@
import InpaintModal from "./InpaintModal.svelte";
import EditModal from "./EditModal.svelte";
import RePoseModal from "./RePoseModal.svelte";
import ExtendModal from "./ExtendModal.svelte";
import SmoothModal from "./SmoothModal.svelte";
import ConfirmModal from "./sidebar/ConfirmModal.svelte";
import LineageContextMenu from "./LineageContextMenu.svelte";
import { buildRender, ancestry, CHILDREN_PER_ROW_CAP, CHILDREN_REVEAL_STEP } from "../lib/lineage-lanes.js";
@@ -33,6 +35,12 @@
onReposeCaps = null, // () => Promise<{recipes}> — installable pose-transfer recipes
onReposeRun = null, // (opts) => tracker — background pose-transfer render
onMountPoser = null, // (el, {width,height,outputMode}) => Promise<handle> — detached 3D poser
onExtendPrepare = null, // (hash) => Promise<{lastFrameFilename,sourceRef,fps,frameCount,templateId,...}>
onExtendRun = null, // (opts) => tracker — background video continuation render
onSmoothCheck = null, // () => bool — RIFE pack + core video nodes registered
onSmoothInstall = null, // () => Promise — offer to install the RIFE pack
onSmoothPrepare = null, // (hash) => Promise<{sourceRef,fps,frameCount,...}>
onSmoothRun = null, // (opts) => tracker — background RIFE interpolation
autoEdit = false,
} = $props();
@@ -387,6 +395,14 @@
// Per-parent override of how many siblings render individually before the rest
// fold into a "+N" bundle chip; bumped by CHILDREN_REVEAL_STEP on a chip click.
let lineageRevealed = $state(new Map());
// Hashes the user pinned: a pinned sibling never folds into a +N bundle. Persisted
// per family alongside the expansion overlay.
let pinnedHashes = $state(new Set());
function togglePin(hash) {
const next = new Set(pinnedHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash);
pinnedHashes = next;
}
let subwayAnchor = $derived(
lineageFocusHash && lineageTree.byHash.has(lineageFocusHash) ? lineageFocusHash : displayedHash
);
@@ -394,7 +410,7 @@
// subtree (no parent_hash mutation, no effect on which image is displayed/loaded).
let focusRootHash = $state(null);
let laneRender = $derived(buildRender(lineageTree, subwayAnchor, brokenOut,
{ displayedHash, revealed: lineageRevealed, rootHash: focusRootHash }));
{ displayedHash, revealed: lineageRevealed, rootHash: focusRootHash, pinned: pinnedHashes }));
let lanesActive = $derived((brokenOut.size > 0 || !!focusRootHash) && lineageList.length > 1);
// is `desc` the focus root or a descendant of it? (guarded parent_hash climb)
function isInSubtree(desc, rootHash) {
@@ -448,15 +464,16 @@
const prefs = readLanePrefs()[root];
brokenOut = new Set((prefs?.brokenOut || []).filter(h => lineageTree.byHash.has(h)));
lineageRevealed = new Map((prefs?.revealed || []).filter(([k]) => lineageTree.byHash.has(k)));
pinnedHashes = new Set((prefs?.pinned || []).filter(h => lineageTree.byHash.has(h)));
});
// persist after hydration; an empty overlay drops the entry rather than storing {}
$effect(() => {
const root = familyRootHash;
const bo = [...brokenOut], rv = [...lineageRevealed];
const bo = [...brokenOut], rv = [...lineageRevealed], pn = [...pinnedHashes];
if (!root || root !== hydratedRoot) return;
try {
const all = readLanePrefs();
if (bo.length || rv.length) all[root] = { brokenOut: bo, revealed: rv };
if (bo.length || rv.length || pn.length) all[root] = { brokenOut: bo, revealed: rv, pinned: pn };
else delete all[root];
localStorage.setItem(LANE_PREFS_KEY, JSON.stringify(all));
} catch {}
@@ -517,7 +534,9 @@
// a plain click clears any selection and navigates.
function handleNodeClick(e, item) {
if (compareDropdownOpen) {
if (item.hash !== displayedHash) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
// mirror the dropdown's video filter — a video can't be the clip-path
// "after" frame, so picking one would paint raw bytes into an <img>
if (item.hash !== displayedHash && !isVideoItem(item)) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
return;
}
if (e.ctrlKey || e.metaKey || e.shiftKey) { toggleSelect(item.hash); return; }
@@ -692,7 +711,9 @@
});
let compareTargets = $derived(
lineageList.filter(item => item?.hash !== displayedHash)
// wipe-compare only makes sense between two stills — a video can't be the
// clip-path "after" frame, so it never appears as a compare target
lineageList.filter(item => item?.hash !== displayedHash && !isVideoItem(item))
);
let hasCompareTargets = $derived(compareTargets.length > 0);
let compareImageUrl = $derived(compareTargetHash ? imageUrl(compareTargetHash) : null);
@@ -837,6 +858,45 @@
if (img?._directUrl) return img._directUrl;
return apiURL(`/promptchain/thumb/${hash}`);
}
// A thumbnail <img> can latch onto a 404 from a request that raced the
// server-side record (the thumb is written as part of recording); a broken
// <img> never retries itself, so a transient miss leaves a lineage node blank
// forever. Cache-bust and re-fetch on each error — the 404 is served no-store,
// so the thumb is on disk by now. Bounded, mirrors the gallery's retry; no polling.
function retryThumb(e) {
const img = e.currentTarget;
const n = +img.dataset.pcrRetry || 0;
if (n >= 3) return;
img.dataset.pcrRetry = String(n + 1);
const u = new URL(img.src, location.href);
u.searchParams.set("r", String(n + 1));
img.src = u.toString();
}
// Video outputs (Wan/AnimateDiff/…) are served by /promptchain/image/<hash>
// with their real mime type, but the hash-keyed URL carries no extension to
// detect them by. The displayed item's filename — from the gallery entry, its
// family node, or the fetched meta — is what tells us to render a <video>
// instead of an <img>. Thumbnails already poster-frame videos server-side, so
// the only signal a thumb is a clip is the ▶ badge keyed off this.
// superset of every backend video set so detection can't drift below them:
// browse_api _VIDEO_EXTS (…avi) and thumbs VIDEO_SUFFIXES (…mkv) both classify
// formats this regex must also accept, or a browse-opened clip falls through to
// a broken <img>.
const VIDEO_EXT_RE = /\.(mp4|webm|m4v|mkv|mov|avi)(\?|$)/i;
function isVideoItem(item) {
return VIDEO_EXT_RE.test(item?.filename || "");
}
function isVideoHash(hash) {
if (!hash) return false;
const entry = imageByHash.get(hash);
if (entry?.filename) return isVideoItem(entry);
const fam = lineageList.find((i) => i?.hash === hash);
if (fam?.filename) return isVideoItem(fam);
if (imageInfo?.hash === hash) return isVideoItem(imageInfo);
return false;
}
let displayedIsVideo = $derived(isVideoHash(displayedHash));
// timeline navigation — moves scrubber position and syncs displayed image
// zoom/pan is preserved across navigation (only resets on double-click or first open)
@@ -853,8 +913,26 @@
// lineage navigation — only changes displayed image, not timeline position
function navigateLineage(delta) {
// walk the VISIBLE strip only — collapsed bundles are skipped; expanding
// one brings its branch into the walk
// In the expanded tree, Up/Down move by GENERATION among the RENDERED nodes
// (parent / primary child), not the flat strip order — which can contain nodes
// the tree doesn't show.
if (lanesActive) {
const renderedReal = (h) => laneRender.nodes.some(n => n.kind === "node" && n.hash === h);
if (delta < 0) {
const p = lineageTree.byHash.get(displayedHash)?.parent_hash;
if (p && renderedReal(p)) { lineageJump(p); exitCompare(); }
} else {
const kids = (lineageTree.childrenOf.get(displayedHash) || []).map(k => k.hash).filter(renderedReal);
if (kids.length) {
const a = ancestry(lineageTree, subwayAnchor);
lineageJump(kids.find(h => a.set.has(h)) || kids[0]);
exitCompare();
}
}
return;
}
// strip mode: walk the VISIBLE strip only — collapsed bundles are skipped;
// expanding one brings its branch into the walk
const idx = lineageVisible.findIndex(item => item.hash === displayedHash);
if (idx < 0) return;
const next = idx + delta;
@@ -864,8 +942,20 @@
}
}
// Left/Right in the expanded tree hop between siblings at the same depth (by column).
function navigateTreeSibling(delta) {
const reals = laneRender.nodes.filter(n => n.kind === "node");
const cur = reals.find(n => n.hash === displayedHash);
if (!cur) return;
const row = reals.filter(n => n.depth === cur.depth).sort((a, b) => a.x - b.x);
const i = row.findIndex(n => n.hash === displayedHash);
const next = row[i + delta];
if (next) { lineageJump(next.hash); exitCompare(); }
}
// compare mode
function enterCompareWith(targetHash, label) {
if (isVideoHash(targetHash)) return; // a video can't be the wipe "after" frame
if (compareMode && compareTargetHash === targetHash) { exitCompare(); return; }
const fresh = !compareMode;
compareTargetHash = targetHash;
@@ -888,6 +978,13 @@
compareDropdownOpen = false;
}
// Navigating onto a video (e.g. up/down the family) drops out of compare —
// the wipe overlay paints an <img> that can't show a clip. Writes only when
// already comparing, so it can't loop.
$effect(() => {
if (displayedIsVideo && (compareMode || compareDropdownOpen)) exitCompare();
});
function handleSliderDown(e) {
e.preventDefault();
e.stopPropagation();
@@ -1181,6 +1278,168 @@
reposeTracker = null;
}
// ── extend (video continuation — viewer post-process for video clips) ──
let extendModalOpen = $state(false);
let extendPrepared = $state(null); // { lastFrameFilename, lastFrameUrl, sourceRef, fps, frameCount, templateId, defaults, recipeDefaults, lightningAvailable }
let extendProgress = $state(null);
let extendPreparing = $state(false);
let extendLastEntry = $state(null); // result of the latest run, for the "Done" jump
let extendTracker = null, extendUnsub = null;
// Splice a freshly-rendered result into the timeline and navigate to it, so the
// user lands on the new clip when processing finishes and a follow-up action
// (Extend/Smooth) runs on it — not the source. Mirrors inpaintSaved.
function landOnResult(entry) {
if (!entry?.hash) return;
const existingIdx = images.findIndex((i) => i.hash === entry.hash);
if (existingIdx >= 0) {
currentIndex = existingIdx;
} else {
const srcIdx = images.findIndex((i) => i.hash === displayedHash);
const insertAt = srcIdx >= 0 ? srcIdx + 1 : images.length;
images = [...images.slice(0, insertAt), entry, ...images.slice(insertAt)];
currentIndex = insertAt;
}
displayedHash = entry.hash;
}
async function openExtend() {
if (extendPreparing || !onExtendPrepare || !onExtendRun || !displayedHash) return;
extendPreparing = true;
try {
const prepared = await onExtendPrepare(displayedHash);
if (prepared?.lastFrameFilename) {
extendPrepared = prepared;
extendProgress = null;
extendModalOpen = true;
}
} catch (e) {
console.error("[PromptChain] extend prepare failed", e);
}
extendPreparing = false;
}
function confirmExtend(opts) {
if (!onExtendRun || !extendPrepared) return;
// Recipe = which template renders the continuation. "lightning" swaps to the
// "-fast" sibling (4-step distill LoRAs, baked correctly); "normal" keeps the
// vanilla base. All the other fields are optional overrides buildExtendGraph
// applies on top of the template (omitted => template default).
const base = extendPrepared.templateId;
const templateId = opts.recipe === "lightning" ? `${base}-fast` : base;
const tracker = onExtendRun({
templateId,
lastFrameFilename: extendPrepared.lastFrameFilename,
parentFilename: extendPrepared.sourceRef,
length: Math.min(161, opts.length || extendPrepared.frameCount || 81), // ceiling: one continuation clip, never a long source's full frame count
width: opts.width || undefined,
height: opts.height || undefined,
outFps: opts.outFps || undefined,
steps: opts.steps, cfg: opts.cfg, shift: opts.shift,
sampler: opts.sampler, scheduler: opts.scheduler,
seed: opts.seed, // undefined unless the user pinned one
promptDoc: opts.promptDoc,
stitch: opts.stitch,
sourceVideoFilename: extendPrepared.sourceRef,
fps: extendPrepared.fps,
});
extendTracker = tracker;
extendUnsub?.();
extendUnsub = tracker.subscribe((state) => {
if (state.phase === "cancelled") { extendProgress = null; return; }
extendProgress = state;
// Keep the modal open on done so the user can compare, tweak settings, and
// Run Again; remember the result so "Done" can jump to it (finishExtend).
if (state.phase === "done" && state.entry?.hash) extendLastEntry = state.entry;
});
}
function finishExtend() {
if (extendLastEntry) { landOnResult(extendLastEntry); showNewGenBanner(null); }
closeExtend();
}
function closeExtend() {
extendModalOpen = false;
extendPrepared = null;
extendProgress = null;
extendLastEntry = null;
extendUnsub?.();
extendUnsub = null;
extendTracker = null;
}
// ── smooth (RIFE interpolation — viewer post-process for video clips) ──
let smoothModalOpen = $state(false);
let smoothPrepared = $state(null); // { sourceRef, fps, frameCount }
let smoothProgress = $state(null);
let smoothPreparing = $state(false);
let smoothLastEntry = $state(null); // result of the latest run, for the "Done" jump
let smoothTracker = null, smoothUnsub = null;
async function openSmooth() {
if (smoothPreparing || !onSmoothPrepare || !onSmoothRun || !displayedHash) return;
// Gate on the RIFE pack: offer to install it on first use, then bail (the
// user re-opens once it's registered). Same contract as the inpaint packs.
if (onSmoothCheck && !onSmoothCheck()) { onSmoothInstall?.(); return; }
smoothPreparing = true;
try {
const prepared = await onSmoothPrepare(displayedHash);
if (prepared?.sourceRef) {
smoothPrepared = prepared;
smoothProgress = null;
smoothModalOpen = true;
}
} catch (e) {
console.error("[PromptChain] smooth prepare failed", e);
}
smoothPreparing = false;
}
function confirmSmooth(opts) {
if (!onSmoothRun || !smoothPrepared) return;
// CreateVideo caps fps at 120, and ComfyUI REJECTS (not clamps) over-max — so
// smoothing an already-high-fps clip (32×4=128) would hard-fail the queue.
// Cap the multiplier so source_fps × mult never exceeds 120; keeps fps and
// frame count in sync (vs clamping outFps alone, which desyncs playback speed).
const srcFps = smoothPrepared.fps || 16;
const maxMult = Math.max(2, Math.floor(120 / srcFps));
const mult = Math.min(opts.multiplier || 2, maxMult);
const tracker = onSmoothRun({
sourceVideoFilename: smoothPrepared.sourceRef,
multiplier: mult,
outFps: Math.round(srcFps * mult),
ckptName: opts.ckptName,
scaleFactor: opts.scaleFactor,
ensemble: opts.ensemble,
fastMode: opts.fastMode,
parentFilename: smoothPrepared.sourceRef,
});
smoothTracker = tracker;
smoothUnsub?.();
smoothUnsub = tracker.subscribe((state) => {
if (state.phase === "cancelled") { smoothProgress = null; return; }
smoothProgress = state;
// Stay open on done for compare / Run Again; remember the result for "Done".
if (state.phase === "done" && state.entry?.hash) smoothLastEntry = state.entry;
});
}
function finishSmooth() {
if (smoothLastEntry) { landOnResult(smoothLastEntry); showNewGenBanner(null); }
closeSmooth();
}
function closeSmooth() {
smoothModalOpen = false;
smoothPrepared = null;
smoothProgress = null;
smoothLastEntry = null;
smoothUnsub?.();
smoothUnsub = null;
smoothTracker = null;
}
// ── inpaint ──
let inpaintModalOpen = $state(false);
let inpaintPrepared = $state(null);
@@ -1248,6 +1507,7 @@
async function openEdit(targetHash = displayedHash) {
if (editPreparing || !onEditPrepare || !onEditSave) return;
if (isVideoHash(targetHash)) return; // the editor can't open a video clip
const entry = images.find((i) => i.hash === targetHash) || { hash: targetHash };
editPreparing = true;
try {
@@ -1522,14 +1782,16 @@
requestDeleteBatch();
return;
}
if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && hasCompareTargets) {
if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && hasCompareTargets && !displayedIsVideo) {
e.preventDefault();
if (compareMode) exitCompare();
else compareDropdownOpen = !compareDropdownOpen;
return;
}
if (e.key === "ArrowLeft") { navigate(-1); return; }
if (e.key === "ArrowRight") { navigate(1); return; }
// In the expanded tree the arrows navigate the 2D family (Up/Down = generation,
// Left/Right = siblings); in the strip, Left/Right stay on the timeline.
if (e.key === "ArrowLeft") { if (lanesActive) { e.preventDefault(); navigateTreeSibling(-1); } else navigate(-1); return; }
if (e.key === "ArrowRight") { if (lanesActive) { e.preventDefault(); navigateTreeSibling(1); } else navigate(1); return; }
if (e.key === "ArrowUp") { e.preventDefault(); navigateLineage(-1); return; }
if (e.key === "ArrowDown") { e.preventDefault(); navigateLineage(1); return; }
}
@@ -1715,6 +1977,8 @@
expandBranches(item);
} else if (action === "focus-branch") {
focusBranch(hash);
} else if (action === "pin") {
togglePin(hash);
} else if (action === "copy-path") {
copyPath(hash);
} else if (action === "edit") {
@@ -1900,8 +2164,9 @@
data-hash={item.hash}
class:current={item.hash === displayedHash}
class:selected={selectedHashes.has(item.hash)}
class:pinned={pinnedHashes.has(item.hash)}
class:orphaned={item.orphaned === 1}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash && !isVideoItem(item)}
class:compare-left={compareMode && item.hash === displayedHash}
class:compare-right={compareMode && item.hash === compareTargetHash}
onclick={(e) => handleNodeClick(e, item)}
@@ -1909,8 +2174,10 @@
onmouseleave={hideTip}
oncontextmenu={(e) => { hideTip(); openLineageMenu(e, item); }}
>
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" />
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" onerror={retryThumb} />
{#if isVideoItem(item)}<div class="pcr-viewer-vidbadge" aria-hidden="true">▶</div>{/if}
{#if selectedHashes.has(item.hash)}<div class="pcr-lin-selcheck">✓</div>{/if}
{#if pinnedHashes.has(item.hash)}<div class="pcr-lin-pinbadge" title="Pinned — kept out of bundles">★</div>{/if}
<div class="pcr-viewer-lineage-dot"
class:dot-root={item.hash === laneRender.rootHash}
class:dot-current={item.hash === displayedHash}
@@ -1987,7 +2254,7 @@
onclick={() => revealMore(node.parent)}>
<div class="pcr-viewer-lineage-bundle-stack">
{#each node.preview as p (p)}
<img src={thumbUrl(p)} alt="" draggable="false" loading="lazy" />
<img src={thumbUrl(p)} alt="" draggable="false" loading="lazy" onerror={retryThumb} />
{/each}
</div>
<span class="pcr-viewer-lineage-bundle-count">+{node.count}</span>
@@ -2013,8 +2280,9 @@
class:descendant={i > lineageDisplayCurrentIdx}
class:branch={d.branch}
class:selected={selectedHashes.has(item.hash)}
class:pinned={pinnedHashes.has(item.hash)}
class:orphaned={item.orphaned === 1}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash && !isVideoItem(item)}
class:compare-left={compareMode && item.hash === displayedHash}
class:compare-right={compareMode && item.hash === compareTargetHash}
onclick={(e) => handleNodeClick(e, item)}
@@ -2022,8 +2290,10 @@
onmouseleave={hideTip}
oncontextmenu={(e) => { hideTip(); openLineageMenu(e, item); }}
>
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" />
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" onerror={retryThumb} />
{#if isVideoItem(item)}<div class="pcr-viewer-vidbadge" aria-hidden="true">▶</div>{/if}
{#if selectedHashes.has(item.hash)}<div class="pcr-lin-selcheck">✓</div>{/if}
{#if pinnedHashes.has(item.hash)}<div class="pcr-lin-pinbadge" title="Pinned — kept out of bundles">★</div>{/if}
<div class="pcr-viewer-lineage-dot"
class:dot-root={item.hash === lineageList[0]?.hash}
class:dot-current={i === lineageDisplayCurrentIdx}
@@ -2051,7 +2321,7 @@
{#if d.kind === "bundle"}
<div class="pcr-viewer-lineage-bundle-stack">
{#each d.preview as p (p.hash)}
<img src={thumbUrl(p.hash)} alt="" draggable="false" loading="lazy" />
<img src={thumbUrl(p.hash)} alt="" draggable="false" loading="lazy" onerror={retryThumb} />
{/each}
</div>
<span class="pcr-viewer-lineage-bundle-count">+{d.count}</span>
@@ -2109,17 +2379,42 @@
{#if !imageLoaded && !imageError}
<div class="pcr-viewer-spinner"></div>
{/if}
<img
bind:this={mainImageEl}
src={mainImageSrc(displayedHash)}
alt=""
draggable="false"
class="pcr-viewer-image"
class:hidden={!imageLoaded}
style="transform: translate({panX}px, {panY}px) scale({zoom})"
onload={() => { imageLoaded = true; }}
onerror={() => { imageError = true; imageLoaded = false; }}
/>
{#if displayedIsVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<!-- stopPropagation keeps the container's pan/gesture handler (which
preventDefaults) from swallowing the native transport controls;
wheel still bubbles up for zoom. poster shows the server frame
while the clip buffers. -->
<video
bind:this={mainImageEl}
src={mainImageSrc(displayedHash)}
poster={imageByHash.get(displayedHash)?._directUrl ? null : thumbUrl(displayedHash)}
class="pcr-viewer-image"
class:hidden={!imageLoaded}
style="transform: translate({panX}px, {panY}px) scale({zoom})"
onloadeddata={() => { imageLoaded = true; }}
onerror={() => { imageError = true; imageLoaded = false; }}
onpointerdown={(e) => e.stopPropagation()}
ondblclick={(e) => e.stopPropagation()}
autoplay
loop
muted
playsinline
controls
></video>
{:else}
<img
bind:this={mainImageEl}
src={mainImageSrc(displayedHash)}
alt=""
draggable="false"
class="pcr-viewer-image"
class:hidden={!imageLoaded}
style="transform: translate({panX}px, {panY}px) scale({zoom})"
onload={() => { imageLoaded = true; }}
onerror={() => { imageError = true; imageLoaded = false; }}
/>
{/if}
{#if compareMode && compareImageUrl}
<img
src={compareImageUrl}
@@ -2215,7 +2510,8 @@
class:current={thumb.globalIndex === currentIndex}
onclick={() => jumpTo(thumb.globalIndex)}
>
<img src={thumbUrl(thumb.hash)} alt="" draggable="false" loading="lazy" />
<img src={thumbUrl(thumb.hash)} alt="" draggable="false" loading="lazy" onerror={retryThumb} />
{#if isVideoItem(thumb)}<div class="pcr-viewer-vidbadge" aria-hidden="true">▶</div>{/if}
</div>
{/each}
<div class="pcr-viewer-history-fade"></div>
@@ -2256,7 +2552,8 @@
class:current={idx === currentIndex}
onclick={() => jumpTo(idx)}
>
<img src={thumbUrl(img.hash)} alt="" draggable="false" loading="lazy" />
<img src={thumbUrl(img.hash)} alt="" draggable="false" loading="lazy" onerror={retryThumb} />
{#if isVideoItem(img)}<div class="pcr-viewer-vidbadge" aria-hidden="true">▶</div>{/if}
</div>
{/each}
</div>
@@ -2292,7 +2589,7 @@
</div>
<!-- compare row -->
{#if hasCompareTargets}
{#if hasCompareTargets && !displayedIsVideo}
<div class="pcr-viewer-toolbar-row pcr-viewer-compare-container">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -2370,8 +2667,9 @@
</div>
{/if}
<!-- edit row -->
{#if onEditSave}
<!-- edit row — image-only: Edit/inpaint/upscale run on a still raster, so
it's hidden for video clips (no meaningful editor handoff exists) -->
{#if onEditSave && !displayedIsVideo}
<div class="pcr-viewer-toolbar-row">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -2394,6 +2692,42 @@
</div>
{/if}
<!-- extend row — video clips only: render the next clip from the last frame -->
{#if onExtendRun && displayedIsVideo}
<div class="pcr-viewer-toolbar-row">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-toolbar-btn"
onclick={() => { if (!extendPreparing) openExtend(); }}
title="Render the next clip, continuing from this video's last frame"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 16 12 5 21 5 3"/><line x1="20" y1="4" x2="20" y2="20"/>
</svg>
<span>{extendPreparing ? "Reading..." : "Extend"}</span>
</div>
</div>
{/if}
<!-- smooth row — video clips only: RIFE frame interpolation -->
{#if onSmoothRun && displayedIsVideo}
<div class="pcr-viewer-toolbar-row">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-toolbar-btn"
onclick={() => { if (!smoothPreparing) openSmooth(); }}
title="Interpolate extra frames (RIFE) for smoother, higher-fps motion"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h4l3-9 4 18 3-9h4"/>
</svg>
<span>{smoothPreparing ? "Reading..." : "Smooth"}</span>
</div>
</div>
{/if}
<!-- copy link row -->
<div class="pcr-viewer-toolbar-row pcr-viewer-toolbar-row-equal">
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -2642,6 +2976,31 @@
onCancel={() => { if (reposeProgress && ["building","queueing","running"].includes(reposeProgress.phase)) reposeTracker?.cancel?.(); else closeRepose(); }}
/>
<ExtendModal
open={extendModalOpen}
lastFrameUrl={extendPrepared?.lastFrameUrl || ""}
fps={extendPrepared?.fps || 16}
frameCount={extendPrepared?.frameCount || 0}
lightningAvailable={extendPrepared?.lightningAvailable ?? true}
defaults={extendPrepared?.defaults || null}
recipeDefaults={extendPrepared?.recipeDefaults || null}
mountPromptEditor={onMountPromptEditor}
progress={extendProgress}
onRun={confirmExtend}
onDone={finishExtend}
onCancel={() => { if (extendProgress && ["building","queueing","running"].includes(extendProgress.phase)) extendTracker?.cancel?.(); else closeExtend(); }}
/>
<SmoothModal
open={smoothModalOpen}
fps={smoothPrepared?.fps || 16}
frameCount={smoothPrepared?.frameCount || 0}
progress={smoothProgress}
onRun={confirmSmooth}
onDone={finishSmooth}
onCancel={() => { if (smoothProgress && ["building","queueing","running"].includes(smoothProgress.phase)) smoothTracker?.cancel?.(); else closeSmooth(); }}
/>
<ConfirmModal
open={confirmDeleteOpen}
title={deleteBatch?.length ? `Delete ${deleteBatch.length} images` : "Delete image"}
@@ -2659,6 +3018,7 @@
y={lineageMenu.y}
canExpand={lineageMenu.item ? canExpand(lineageMenu.item) : false}
canFocus={lineageMenu.item ? (lineageTree.childrenOf.get(lineageMenu.item.hash)?.length > 0 && lineageMenu.item.hash !== focusRootHash) : false}
pinned={lineageMenu.item ? pinnedHashes.has(lineageMenu.item.hash) : false}
canEdit={!!onEditSave}
canDelete={!!onDelete}
isLocal={isLocalClient}
@@ -2746,6 +3106,28 @@
}
/* centered over the thumb so it never collides with the corner badges (dot,
layers, fork pip / expander, opbadge) — the .selected ring already frames it */
/* pin marker — top-left; shares the corner with the layers badge, which shifts right when both show */
.pcr-lin-pinbadge {
position: absolute;
top: 3px;
left: 3px;
width: 15px;
height: 15px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
line-height: 1;
border-radius: 4px;
background: rgba(20, 20, 20, 0.82);
color: #ffd24a;
box-shadow: 0 0 0 1px rgba(255, 200, 60, 0.6);
pointer-events: none;
z-index: 3;
}
.pcr-viewer-lineage-node.pinned .pcr-viewer-lineage-layers {
left: 21px;
}
.pcr-lin-selcheck {
position: absolute;
top: 50%;
@@ -3292,6 +3674,7 @@
flex: 1;
}
.pcr-viewer-history-thumb {
position: relative;
flex-shrink: 0;
width: 40px;
height: 40px;
@@ -3386,6 +3769,7 @@
.pcr-viewer-history-grid::-webkit-scrollbar-thumb:hover { background: #555; }
.pcr-viewer-grid-thumb {
position: relative;
width: 56.5px;
height: 56.5px;
border: 2px solid transparent;
@@ -3399,6 +3783,27 @@
.pcr-viewer-grid-thumb:hover:not(.current) { border-color: #666; transform: scale(1.05); z-index: 1; }
.pcr-viewer-grid-thumb img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
/* ▶ overlay marking a poster-framed thumbnail as a playable video clip */
.pcr-viewer-vidbadge {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding-left: 1px;
font-size: 9px;
line-height: 1;
color: #fff;
background: rgba(0, 0, 0, 0.55);
border-radius: 50%;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
/* metadata content */
.pcr-viewer-meta-content {
flex: 1;
@@ -5,6 +5,7 @@
x, y,
canExpand = false,
canFocus = false,
pinned = false,
canEdit = false,
canDelete = false,
isLocal = false,
@@ -52,7 +53,8 @@
{#if canFocus}
<button class="pcr-ctx-item" onclick={() => act("focus-branch")}>Focus this branch</button>
{/if}
{#if (canExpand || canFocus) && (canEdit || canDelete || isLocal)}<div class="pcr-ctx-sep"></div>{/if}
<button class="pcr-ctx-item" onclick={() => act("pin")}>{pinned ? "Unpin" : "Pin"}</button>
{#if canEdit || canDelete || isLocal}<div class="pcr-ctx-sep"></div>{/if}
{#if canEdit}
<button class="pcr-ctx-item" onclick={() => act("edit")}>Edit</button>
{/if}
+170
View File
@@ -0,0 +1,170 @@
<script>
// "Smooth Video" modal — RIFE frame interpolation (2x/3x/4x fps) on the source
// clip. Background graph (smooth-background.js); result records as a lineage
// child. Multiplier is primary; model/scale/quality knobs live under Advanced.
let {
open = false,
fps = 16,
frameCount = 0,
progress = null,
onRun,
onDone, // () => land on the result + close (the "jump")
onCancel,
} = $props();
// Newer arches first; rife49 is the proven default and the one bundled. A ckpt
// that isn't on disk auto-downloads on first render.
const CKPTS = ["rife49.pth", "rife426.pth", "rife417.pth", "rife47.pth"];
const SCALES = [0.25, 0.5, 1.0, 2.0, 4.0];
let mult = $state(2);
let advancedOpen = $state(false);
let ckptName = $state("rife49.pth");
let scaleFactor = $state(1.0); // <1 for high-res (less VRAM); 1.0 default
let ensemble = $state(true); // higher quality, slower
let fastMode = $state(true); // faster, slight quality trade-off
let primed = false;
$effect(() => {
if (open && !primed) {
mult = 2; advancedOpen = false; ckptName = "rife49.pth";
scaleFactor = 1.0; ensemble = true; fastMode = true; primed = true;
}
if (!open) primed = false;
});
let phase = $derived(progress?.phase || null);
let running = $derived(phase === "building" || phase === "queueing" || phase === "running");
let done = $derived(phase === "done");
let pct = $derived(progress?.max ? Math.round((progress.value / progress.max) * 100) : 0);
// RIFE outputs (frames-1)*mult + 1 frames; close enough to show the gist.
let outFrames = $derived(frameCount ? (frameCount - 1) * mult + 1 : 0);
function run() {
if (running) return;
onRun?.({ multiplier: mult, ckptName, scaleFactor, ensemble, fastMode });
}
function close() { if (done) onDone?.(); else onCancel?.(); }
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-sm-overlay" onclick={(e) => { if (e.target === e.currentTarget && !running) close(); }}>
<div class="pcr-sm-modal">
<div class="pcr-sm-title">Smooth Video</div>
<div class="pcr-sm-sub">
Adds in-between frames with RIFE for smoother motion. {fps} → {fps * mult} fps{frameCount ? ` · ${frameCount} → ${outFrames} frames` : ""}. Saved as a lineage child.
</div>
<div class="pcr-sm-label">Interpolation</div>
<div class="pcr-sm-mults">
{#each [2, 3, 4] as m}
<button class="pcr-sm-mult" class:pcr-sm-active={mult === m} disabled={running} onclick={() => { mult = m; }}>{m}×</button>
{/each}
</div>
<button class="pcr-sm-adv-toggle" onclick={() => advancedOpen = !advancedOpen}>
{advancedOpen ? "▾" : "▸"} Advanced
</button>
{#if advancedOpen}
<div class="pcr-sm-adv">
<label class="pcr-sm-field">
<span>RIFE model</span>
<select bind:value={ckptName} disabled={running}>
{#each CKPTS as c}<option value={c}>{c.replace(".pth", "")}</option>{/each}
</select>
</label>
<label class="pcr-sm-field">
<span>Scale <small>lower = less VRAM (high-res)</small></span>
<select bind:value={scaleFactor} disabled={running}>
{#each SCALES as s}<option value={s}>{s}×</option>{/each}
</select>
</label>
<label class="pcr-sm-check">
<input type="checkbox" bind:checked={ensemble} disabled={running} />
<span>Ensemble <small>higher quality, slower</small></span>
</label>
<label class="pcr-sm-check">
<input type="checkbox" bind:checked={fastMode} disabled={running} />
<span>Fast mode <small>faster, slight quality trade-off</small></span>
</label>
</div>
{/if}
{#if progress}
<div class="pcr-sm-progress">
{#if phase === "error"}
<div class="pcr-sm-err">⚠ {progress.message || "render failed"}</div>
{:else if done}
<div class="pcr-sm-done">✓ Smoothed. Adjust and Run Again, or Done to view it.</div>
{#if progress.resultUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="pcr-sm-result" src={progress.resultUrl} controls autoplay loop muted></video>
{/if}
{:else}
<div class="pcr-sm-bar"><div class="pcr-sm-fill" style="width:{pct}%"></div></div>
<div class="pcr-sm-phase">{phase}{progress.max ? ` · ${pct}%` : "…"}</div>
{#if progress.previewUrl}
<img class="pcr-sm-preview" src={progress.previewUrl} alt="preview" />
{/if}
{/if}
</div>
{/if}
<div class="pcr-sm-actions">
{#if running}
<button class="pcr-sm-btn" onclick={() => onCancel?.()}>Cancel</button>
{:else if done}
<button class="pcr-sm-btn" onclick={() => onDone?.()}>Done</button>
<button class="pcr-sm-btn pcr-sm-primary" onclick={run}>Run Again</button>
{:else}
<button class="pcr-sm-btn" onclick={() => onCancel?.()}>Close</button>
<button class="pcr-sm-btn pcr-sm-primary" onclick={run}>Smooth</button>
{/if}
</div>
</div>
</div>
{/if}
<style>
.pcr-sm-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center; z-index: 10011;
}
.pcr-sm-modal { background: #1e1e1e; border: 1px solid #444; border-radius: 8px; padding: 20px 24px; min-width: 360px; max-width: 460px; max-height: 88vh; overflow-y: auto; }
.pcr-sm-title { font-size: 15px; font-weight: 600; color: #eee; margin-bottom: 4px; }
.pcr-sm-sub { font-size: 11px; color: #888; margin-bottom: 14px; line-height: 1.4; }
.pcr-sm-label { font-size: 11px; color: #aaa; margin-bottom: 6px; }
.pcr-sm-mults { display: flex; gap: 8px; }
.pcr-sm-mult {
flex: 1; padding: 8px 0; border: 1px solid #555; border-radius: 4px; background: transparent;
color: #ccc; font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.pcr-sm-mult:hover { border-color: #888; color: #fff; }
.pcr-sm-mult.pcr-sm-active { border-color: #4fc3f7; color: #4fc3f7; background: rgba(79,195,247,0.1); }
.pcr-sm-adv-toggle { margin-top: 12px; background: none; border: none; color: #8bd5f5; font-size: 11px; cursor: pointer; padding: 2px 0; }
.pcr-sm-adv { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; }
.pcr-sm-field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #aaa; }
.pcr-sm-field small, .pcr-sm-check small { color: #777; }
.pcr-sm-field select {
background: #161616; border: 1px solid #444; border-radius: 4px; color: #ddd;
font-size: 12px; padding: 5px 6px; font-family: inherit;
}
.pcr-sm-check { display: flex; align-items: center; gap: 7px; font-size: 11px; color: #aaa; cursor: pointer; }
.pcr-sm-progress { margin-top: 14px; }
.pcr-sm-bar { height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
.pcr-sm-fill { height: 100%; background: #4fc3f7; border-radius: 3px; transition: width 0.3s; }
.pcr-sm-phase { font-size: 11px; color: #aaa; margin-top: 6px; text-transform: capitalize; }
.pcr-sm-preview, .pcr-sm-result { width: 100%; margin-top: 8px; border-radius: 4px; max-height: 240px; }
.pcr-sm-err { font-size: 12px; color: #f44336; }
.pcr-sm-done { font-size: 12px; color: #4caf50; margin-bottom: 6px; }
.pcr-sm-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.pcr-sm-btn { padding: 6px 16px; font-size: 12px; border: 1px solid #555; border-radius: 4px; background: transparent; color: #ccc; cursor: pointer; }
.pcr-sm-btn:hover { background: rgba(255,255,255,0.08); }
.pcr-sm-btn:disabled { opacity: 0.5; cursor: default; }
.pcr-sm-primary { background: #4fc3f7; color: #111; border-color: #4fc3f7; }
.pcr-sm-primary:hover { background: #39b0e4; }
.pcr-sm-primary:disabled { background: #2a7a9e; }
</style>
@@ -383,16 +383,41 @@
});
function thumbUrl(hash) { return apiURL(`/promptchain/thumb/${hash}`); }
// A thumbnail <img> can latch onto a 404 from a request that raced the record
// (the thumb is generated server-side as part of recording); a broken <img>
// never retries itself. Cache-bust and re-fetch ONCE on the error event — the
// thumb is on disk by now. Event-driven, no polling.
// A thumbnail <img> can latch broken if its request raced the record (the
// thumb is generated server-side as part of recording). A broken <img> never
// retries itself, so cache-bust and re-fetch on the natural error event —
// bounded, event-driven, no polling.
function retryThumb(e) {
const img = e.currentTarget;
if (img.dataset.pcrRetried) return;
img.dataset.pcrRetried = "1";
img.src = img.src + (img.src.includes("?") ? "&" : "?") + "r=1";
const n = +img.dataset.pcrRetry || 0;
if (n >= 3) return;
img.dataset.pcrRetry = String(n + 1);
const u = new URL(img.src, location.href);
u.searchParams.set("r", String(n + 1));
img.src = u.toString();
}
// A thumb that exhausted its retries while the record was still committing
// stays broken forever. The authoritative "record committed" signal is the
// generation-recorded event (fired AFTER recordGeneration's awaited POST, so
// the thumb is provably on disk by now) — re-fetch any latched-broken <img>
// when it lands. Event-driven recovery, no timers.
function healBrokenThumbs() {
if (!galleryEl) return;
for (const img of galleryEl.querySelectorAll("img")) {
if (img.complete && img.naturalWidth === 0) {
img.dataset.pcrRetry = "0";
const u = new URL(img.src, location.href);
u.searchParams.set("r", "heal");
img.src = u.toString();
}
}
}
$effect(() => {
const onRecorded = () => healBrokenThumbs();
window.addEventListener("promptchain:generation-recorded", onRecorded);
return () => window.removeEventListener("promptchain:generation-recorded", onRecorded);
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
+60 -12
View File
@@ -128,6 +128,23 @@
editors[nodeType][widgetName] = val;
}
// Ideogram's noise schedule is computed by Ideogram4Scheduler, which carries its
// OWN width/height that must match the latent or the schedule and the image size
// disagree. The resolution row writes only the latent node, so slave the
// scheduler's width/height to it here (it is intentionally not shown as a second
// resolution row). No-op on any graph without an Ideogram4Scheduler section.
function mirrorIdeogramScheduler(latentNode) {
const sched = detected.find((d) => d.type === "Ideogram4Scheduler");
if (!sched) return;
for (const dim of ["width", "height"]) {
const v = readWidgetValue(latentNode, dim);
if (v !== undefined) {
writeWidgetValue(sched.node, dim, v);
setEditorValue("Ideogram4Scheduler", dim, v);
}
}
}
function setRangeValue(nodeType, widgetName, lo, hi) {
if (!rangeValues[nodeType]) rangeValues[nodeType] = {};
rangeValues[nodeType][`${widgetName}_range`] = [lo, hi];
@@ -151,6 +168,22 @@
presetMenuOpen[nodeType] = !presetMenuOpen[nodeType];
}
// Close an open preset menu when the pointer goes down anywhere outside its
// wrap. Capture phase so it runs before the toggle button's own handler; the
// wrap holds both the button and the menu, so toggling/selecting still works.
// (The modal's own outside-click ignores clicks inside the menu, so this is
// the only thing that dismisses it on an unrelated click.) Event-driven.
$effect(() => {
function onDocDown(e) {
if (e.target?.closest?.(".pcr-resolution-preset-wrap")) return;
for (const k of Object.keys(presetMenuOpen)) {
if (presetMenuOpen[k]) presetMenuOpen[k] = false;
}
}
document.addEventListener("pointerdown", onDocDown, true);
return () => document.removeEventListener("pointerdown", onDocDown, true);
});
// Collapsible section state. The incoming prop is a plain object (not
// $state), so mutating it directly doesn't re-render. Drive a local $state
// and mirror back to the prop so expand state still persists across reopens.
@@ -270,20 +303,22 @@
{#if !rangeMode}
{@const hasResolution = Object.values(config.widgets).some(d => d.type === "resolution")}
{#if hasResolution}
{@const w = readWidgetValue(node, "width")}
{@const h = readWidgetValue(node, "height")}
{@const w = editors[type]?.width}
{@const h = editors[type]?.height}
{#if w !== undefined && h !== undefined}
{@const savedW = savedWidgets["width"]}
{@const savedH = savedWidgets["height"]}
{@const presets = savedWidgets["_resolutions"] || []}
{@const presets = editors[type]?.["_resolutions"] || []}
{@const isPreset = presets.some(p => p.width === w && p.height === h)}
<div class="pcr-model-panel-row pcr-resolution-row">
<span class="pcr-model-panel-label">resolution</span>
<div class="pcr-resolution-controls">
<div class="pcr-resolution-preset-wrap">
<button class="pcr-resolution-preset-btn"
class:pcr-resolution-preset-active={presets.some(p => p.width === w && p.height === h)}
class:pcr-resolution-preset-active={isPreset}
class:pcr-resolution-preset-dimmed={!isPreset}
onclick={(e) => { e.stopPropagation(); togglePresetMenu(type); }}>
{presets.some(p => p.width === w && p.height === h) ? `${w}×${h}` : "Presets"}
{isPreset ? `${w}×${h}` : "Presets"}
</button>
{#if presetMenuOpen[type]}
<div class="pcr-resolution-preset-menu" style="display:block">
@@ -303,6 +338,7 @@
writeWidgetValue(node, "height", p.height);
setEditorValue(type, "width", p.width);
setEditorValue(type, "height", p.height);
mirrorIdeogramScheduler(node);
presetMenuOpen[type] = false;
}}>
<span class="pcr-resolution-preset-item-label">{p.width}×{p.height}</span>
@@ -318,35 +354,38 @@
{/if}
{/each}
<div class="pcr-resolution-preset-add"
class:pcr-resolution-preset-add-disabled={presets.some(p => p.width === w && p.height === h)}
class:pcr-resolution-preset-add-disabled={isPreset}
onclick={() => {
if (!presets.some(p => p.width === w && p.height === h)) {
presets.push({ width: w, height: h });
editors[type]["_resolutions"] = [...presets];
if (!isPreset) {
editors[type]["_resolutions"] = [...presets, { width: w, height: h }];
}
presetMenuOpen[type] = false;
}}>
{presets.some(p => p.width === w && p.height === h) ? `${w}×${h} already saved` : "+ Add Current"}
{isPreset ? `${w}×${h} already saved` : "+ Add Current"}
</div>
</div>
{/if}
</div>
<input type="number" class="pcr-resolution-input"
class:pcr-resolution-input-dimmed={presets.some(p => p.width === w && p.height === h)}
class:pcr-resolution-input-dimmed={isPreset}
class:pcr-resolution-input-active={!isPreset}
value={w} step={8}
onchange={(e) => {
const v = parseInt(e.target.value) || 512;
setEditorValue(type, "width", v);
writeWidgetValue(node, "width", v);
mirrorIdeogramScheduler(node);
}} />
<span class="pcr-resolution-sep">×</span>
<input type="number" class="pcr-resolution-input"
class:pcr-resolution-input-dimmed={presets.some(p => p.width === w && p.height === h)}
class:pcr-resolution-input-dimmed={isPreset}
class:pcr-resolution-input-active={!isPreset}
value={h} step={8}
onchange={(e) => {
const v = parseInt(e.target.value) || 512;
setEditorValue(type, "height", v);
writeWidgetValue(node, "height", v);
mirrorIdeogramScheduler(node);
}} />
</div>
</div>
@@ -726,9 +765,18 @@
color: #5dcaff;
min-width: 90px;
}
/* custom (non-preset) resolution: dim the dropdown, light up the W×H boxes */
.pcr-resolution-preset-dimmed {
opacity: 0.45;
}
.pcr-resolution-input-dimmed {
opacity: 0.4;
}
.pcr-resolution-input-active {
border-color: #5dcaff;
color: #fff;
background: #222b33;
}
.pcr-resolution-preset-menu {
position: fixed;
background: rgba(30, 30, 30, 0.98);
+32 -2
View File
@@ -22,6 +22,7 @@
let panelEl;
let dividerEl;
let imgEl;
let videoEl;
let imgContainerEl;
let isVisible = $state(!!node.properties?.pcrImagePreview);
let panelWidth = $state(node.properties?.pcrImagePanelWidth || DEFAULT_WIDTH);
@@ -106,6 +107,13 @@
} catch { return { filename: "", subfolder: "" }; }
}
// Video outputs (Wan/AnimateDiff/…) arrive through the same executed event as
// images but name an .mp4/.webm file — render those in a <video>, not <img>.
let isVideoUrl = $derived.by(() => {
if (!currentImageUrl) return false;
return /\.(mp4|webm|m4v|mov)(\?|$)/i.test(parseImageUrl(currentImageUrl).filename);
});
let filenameDisplay = $derived.by(() => {
if (shared.isGenerating) return "Generating\u2026";
if (!currentImageUrl) return "";
@@ -171,6 +179,13 @@
if (!isPreviewMode) resetZoom();
}
function handleVideoLoaded() {
imageLoaded = true;
imageError = false;
naturalWidth = videoEl?.videoWidth || 0;
naturalHeight = videoEl?.videoHeight || 0;
}
function handleImageError() {
imageLoaded = false;
imageError = true;
@@ -475,7 +490,21 @@
onpointerup={handlePanEnd}
ondblclick={(e) => { e.stopPropagation(); resetZoom(true); }}
>
{#if currentImageUrl}
{#if currentImageUrl && isVideoUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoEl}
src={currentImageUrl}
style:display={imageLoaded ? "block" : "none"}
onloadeddata={handleVideoLoaded}
onerror={handleImageError}
autoplay
loop
muted
playsinline
controls
></video>
{:else if currentImageUrl}
<img
bind:this={imgEl}
src={currentImageUrl}
@@ -620,7 +649,8 @@
overflow: hidden;
background: var(--pcr-fs-editor-surface, transparent);
}
.pcr-image-container img {
.pcr-image-container img,
.pcr-image-container video {
width: 100%;
height: 100%;
object-fit: contain;
+14
View File
@@ -25,6 +25,7 @@
onToggleImage = () => {},
onToggleAssistant = () => {},
onTogglePose = () => {},
onToggleRegion = () => {},
onOpenFullscreen = () => {},
onResetIterate = null,
docDropdownEl = null,
@@ -131,6 +132,7 @@
else if (action === "output") onToggleOutput();
else if (action === "assistant") { if (aiAssistantEnabled) onToggleAssistant(); }
else if (action === "pose") { if (shared.hasPoseStudio) onTogglePose(); }
else if (action === "region") { if (shared.hasRegionBox) onToggleRegion(); }
else if (action === "collapse") onToggleCollapse();
else if (action === "maximize") onOpenFullscreen();
}
@@ -219,6 +221,18 @@
{@html '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M20.5 6c-2.61.7-5.67 1-8.5 1s-5.89-.3-8.5-1L3 8c1.86.5 4 .83 6 1v13h2v-6h2v6h2V9c2-.17 4.14-.5 6-1l-.5-2zM12 6c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/></svg>'}
</span>
<span
class="pcr-menubar-btn"
class:pcr-menubar-btn-active={shared.regionPanelVisible}
class:pcr-menubar-btn-disabled={!shared.hasRegionBox}
style:color={shared.regionPanelVisible ? "#42b9c4" : ""}
title={shared.hasRegionBox ? "Toggle Region Box panel" : "Add a Region Box node to use this"}
data-action="region"
use:actionClick={"region"}
>
{@html '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M3 5v4h2V5h4V3H5c-1.1 0-2 .9-2 2zm2 10H3v4c0 1.1.9 2 2 2h4v-2H5v-4zm14 4h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4zm0-16h-4v2h4v4h2V5c0-1.1-.9-2-2-2zM7 7v10h10V7H7zm8 8H9V9h6v6z"/></svg>'}
</span>
<span
class="pcr-menubar-btn"
class:pcr-menubar-btn-active={shared.imagePanelVisible}
@@ -8,6 +8,7 @@
import OutputPanel from "./OutputPanel.svelte";
import ImagePanel from "./ImagePanel.svelte";
import PosePanel from "./PosePanel.svelte";
import RegionPanel from "./RegionPanel.svelte";
import Footer from "./Footer.svelte";
import AIAssistant from "./AIAssistant.svelte";
@@ -24,6 +25,7 @@
onToggleImage = () => {},
onToggleAssistant = () => {},
onTogglePose = () => {},
onToggleRegion = () => {},
onOpenFullscreen = () => {},
// doc dropdown DOM element (imperative)
docDropdownEl = null,
@@ -37,10 +39,13 @@
onOutputPanelRegister = null,
onImagePanelRegister = null,
onPosePanelRegister = null,
onRegionPanelRegister = null,
onAIAssistantRegister = null,
onFooterRegister = null,
// returns the active 3D Poser node to dock into the pose panel
getActivePoser = () => null,
// returns the active Region Box node to dock into the region panel
getActiveRegionBox = () => null,
// OutputPanel API callbacks
apiURL = (p) => p,
fetchApi = (p, o) => fetch(p, o),
@@ -83,6 +88,7 @@
onToggleImage={() => onToggleImage()}
onToggleAssistant={() => onToggleAssistant()}
onTogglePose={() => onTogglePose()}
onToggleRegion={() => onToggleRegion()}
{onOpenFullscreen}
{docDropdownEl}
/>
@@ -98,6 +104,14 @@
onToggle={(visible) => { shared.posePanelVisible = visible; }}
onRegister={onPosePanelRegister}
/>
<!-- Region Box panel — also leftmost. Docks the live Region Box canvas. -->
<RegionPanel
{node}
{shared}
{getActiveRegionBox}
onToggle={(visible) => { shared.regionPanelVisible = visible; }}
onRegister={onRegionPanelRegister}
/>
<!-- AIAssistant is always rendered so its DOM can be relocated into
the fullscreen pane-row when entering fullscreen; visibility
toggles via display:none. Same lifecycle pattern as ImagePanel. -->
@@ -0,0 +1,299 @@
<script>
// Region Box panel — left-side surface that hosts the live PromptChain_RegionBox
// canvas. Opening it reparents ("docks") the active Region Box node's canvas DOM
// into this panel so regions are drawn from one roomy spot instead of the cramped
// node body — and, because it's part of the PromptChain node's own widget tree,
// it stays bound to that node.
//
// The canvas is a single relocatable element; we move it in/out rather than
// duplicating it. getActiveRegionBox() comes from main.js, which shares the
// region-box registry with region-box.js (a direct import here would bundle a
// second, non-shared copy). Twin of PosePanel.svelte.
import { onMount, onDestroy } from "svelte";
import { useApi } from "../../lib/api-context.js";
const DEFAULT_WIDTH = 440;
const MIN_WIDTH = 240;
let {
node,
shared,
onToggle = () => {},
onRegister = null,
getActiveRegionBox = () => null,
} = $props();
const { getCanvasScale } = useApi();
let panelEl;
let dividerEl;
let bodyEl;
let isVisible = $state(!!node.properties?.pcrRegionPanel);
let panelWidth = $state(node.properties?.pcrRegionPanelWidth || DEFAULT_WIDTH);
// The Region Box node whose canvas is currently docked here (so hide()/refresh
// can return it home). Plain let — its identity drives imperative dock calls,
// not template reactivity; `hasDock` mirrors it for the placeholder.
let dockedNode = null;
let hasDock = $state(false);
let toggleListener = null;
function emitToggle(visible) {
onToggle(visible);
toggleListener?.(visible);
}
export function setToggleListener(cb) { toggleListener = cb; }
function dock(rb) {
if (!rb || !bodyEl || !rb._pcrRegion?.enterDock) return;
rb._pcrRegion.enterDock(bodyEl);
dockedNode = rb;
hasDock = true;
}
function undock() {
if (dockedNode && dockedNode._pcrRegion?.exitDock) {
try { dockedNode._pcrRegion.exitDock(); } catch (e) { console.error("[PromptChain] region undock error", e); }
}
dockedNode = null;
hasDock = false;
}
// Re-evaluate which Region Box is hosted. Called by main.js when the registry
// changes. Keep the current host while it's alive; only re-dock when it's gone.
export function refreshDock() {
if (!isVisible) return;
if (dockedNode && dockedNode._pcrAlive) return;
if (dockedNode) undock(); // host died — release before re-docking
const active = getActiveRegionBox();
if (active) dock(active);
}
export function show() {
isVisible = true;
if (node.properties) node.properties.pcrRegionPanel = true;
emitToggle(true);
requestAnimationFrame(() => { if (isVisible) dock(getActiveRegionBox()); });
}
export function hide() {
undock();
isVisible = false;
if (node.properties) node.properties.pcrRegionPanel = false;
emitToggle(false);
}
export function toggle() { isVisible ? hide() : show(); }
export function getIsVisible() { return isVisible; }
function getMaxPanelWidth() {
const row = panelEl?.parentElement;
const rowWidth = row?.offsetWidth || 0;
if (!rowWidth) return Number.POSITIVE_INFINITY;
return Math.max(MIN_WIDTH, rowWidth - 120);
}
function clampPanelWidth(value) {
return Math.min(Math.max(MIN_WIDTH, value), getMaxPanelWidth());
}
onMount(() => {
onRegister?.({ toggle, show, hide, getIsVisible, refreshDock, setToggleListener, cleanup });
if (node.properties?.pcrRegionPanel) requestAnimationFrame(() => show());
requestAnimationFrame(() => { panelWidth = clampPanelWidth(panelWidth); });
});
// divider resize — panel is leftmost, so dragging the divider right widens it.
let dividerAc;
onMount(() => {
dividerAc = new AbortController();
let isDragging = false;
let startX = 0;
let startWidth = 0;
const getScale = () => {
const inFs = !!document.querySelector(".pcr-fs-overlay");
return inFs ? 1 : getCanvasScale();
};
document.addEventListener("pointerdown", (e) => {
if (!dividerEl || (e.target !== dividerEl && !dividerEl.contains(e.target))) return;
isDragging = true;
startX = e.clientX;
startWidth = panelEl?.offsetWidth || panelWidth;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const canvas = document.querySelector("canvas.lgraphcanvas");
if (canvas && typeof e.pointerId === "number") {
try { if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); } catch {}
}
}, { capture: true, signal: dividerAc.signal });
document.addEventListener("pointermove", (e) => {
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();
const delta = (e.clientX - startX) / getScale();
panelWidth = clampPanelWidth(startWidth + delta);
}, { capture: true, signal: dividerAc.signal });
document.addEventListener("pointerup", () => {
if (!isDragging) return;
isDragging = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
if (node.properties) node.properties.pcrRegionPanelWidth = panelWidth;
}, { capture: true, signal: dividerAc.signal });
});
const SHOW_CLOSE_BTN = false; // ✕ hidden; toggle via the menubar button
let closeState = $state("hidden"); // hidden | dim | full
function onPanelMove(e) {
if (!panelEl) return;
const r = panelEl.getBoundingClientRect();
const inTop = e.clientY - r.top < Math.max(32, r.height * 0.1);
const inRight = r.right - e.clientX < Math.max(32, r.width * 0.1);
closeState = inTop && inRight ? "full" : "dim";
}
function onPanelLeave() {
closeState = "hidden";
}
function cleanup() {
dividerAc?.abort();
undock();
}
onDestroy(() => {
dividerAc?.abort();
undock();
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={panelEl}
class="pcr-region-panel"
style:width="{panelWidth}px"
style:display={isVisible ? "flex" : "none"}
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
ondblclick={(e) => e.stopPropagation()}
onpointerleave={onPanelLeave}
onpointermovecapture={onPanelMove}
>
{#if SHOW_CLOSE_BTN}
<button class="pcr-region-panel-close-btn" class:pcr-close-dim={closeState === "dim"} class:pcr-close-show={closeState === "full"} title="Close Region Box panel" onclick={(e) => {
e.preventDefault();
e.stopPropagation();
hide();
}}>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
<!-- the Region Box canvas is reparented into here on dock -->
<div bind:this={bodyEl} class="pcr-region-panel-body">
{#if !hasDock}
<div class="pcr-region-panel-placeholder">No Region Box node in the graph</div>
{/if}
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={dividerEl} class="pcr-region-divider" style:display={isVisible ? "flex" : "none"}
ondblclick={(e) => {
e.preventDefault();
e.stopPropagation();
const rb = dockedNode || getActiveRegionBox();
const w = rb?.widgets?.find((x) => x.name === "width")?.value;
const h = rb?.widgets?.find((x) => x.name === "height")?.value;
if (!w || !h) return;
const bodyH = bodyEl?.offsetHeight || panelEl?.offsetHeight || 300;
panelWidth = clampPanelWidth(Math.round(bodyH * (w / h)));
if (node.properties) node.properties.pcrRegionPanelWidth = panelWidth;
}}
></div>
<style>
.pcr-region-panel {
display: none;
flex-direction: column;
background: rgba(0, 0, 0, 0.4);
min-width: 240px;
flex-shrink: 0;
overflow: hidden;
position: relative;
}
:global(.lg-node-widgets) .pcr-region-panel {
background: rgb(0 0 0 / 46%);
}
.pcr-region-panel-close-btn {
position: absolute;
top: 4px;
right: 4px;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
color: #fff;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.9)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.7));
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s;
}
.pcr-region-panel-close-btn.pcr-close-dim {
opacity: 0.3;
pointer-events: auto;
}
.pcr-region-panel-close-btn.pcr-close-show {
opacity: 1;
pointer-events: auto;
}
.pcr-region-panel-body {
flex: 1;
min-height: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--pcr-fs-editor-surface, transparent);
}
.pcr-region-panel-placeholder {
color: rgba(255, 255, 255, 0.3);
font-size: 12px;
font-style: italic;
text-align: center;
padding: 0 12px;
user-select: none;
}
.pcr-region-divider {
width: 4px;
background: #242424;
cursor: col-resize;
flex-shrink: 0;
display: none;
align-items: center;
justify-content: center;
}
.pcr-region-divider::before {
content: '';
width: 2px;
height: 40px;
background: rgba(255, 255, 255, 0.15);
border-radius: 1px;
transition: background 0.15s;
}
.pcr-region-divider:hover::before {
background: rgba(255, 255, 255, 0.3);
}
</style>
@@ -6,6 +6,9 @@
catalogEntry,
onClose,
onModelReady,
// Pre-selects a precision (e.g. the "FP16 ↓" tag the user clicked on an
// installed model row). Falls back to the first declared precision.
initialPrecision = null,
} = $props();
// ── helpers ──────────────────────────────────────────────────────
@@ -54,7 +57,9 @@
const rawFiles = catalogEntry.files || [];
const precisions = extractPrecisions(rawFiles);
let selectedPrecision = $state(precisions[0] || null);
let selectedPrecision = $state(
(initialPrecision && precisions.includes(initialPrecision)) ? initialPrecision : (precisions[0] || null)
);
let resolvedFiles = $derived(
selectedPrecision ? resolveFilesForPrecision(rawFiles, selectedPrecision) : rawFiles
);
+192 -74
View File
@@ -71,8 +71,17 @@
let searchEl;
let panelStyle = $state("");
// Catalog precision installation status: { entryHash: { precision: bool } }
let catalogPrecisionStatus = $state({});
// Per-precision install state for every catalog entry, authoritative from the
// backend ("installed" | "partial" | "missing"). Derived straight from the
// catalog payload — no client-side file probing, so it can't drift from the
// real _all_files_installed check the way the old JS heuristic did (it only
// probed the diffusion file and flagged a precision installed while a
// companion encoder/VAE was still missing).
let catalogPrecisionStatus = $derived.by(() => {
const out = {};
for (const c of catalog) out[c.hash] = c.precision_status || {};
return out;
});
// ── load data ──
async function loadData() {
@@ -98,9 +107,6 @@
modelSettings = settingsRes.settings || {};
}
// Check catalog precision installation status
await checkCatalogPrecisions();
loading = false;
requestAnimationFrame(() => searchEl?.focus());
focusDownloadedModel();
@@ -137,40 +143,6 @@
listEl?.querySelector(".pcr-picker-item-highlight")?.scrollIntoView({ block: "center" });
}
async function checkCatalogPrecisions() {
if (!catalog.length) return;
const filesToCheck = [];
const fileMap = [];
const status = {};
for (const entry of catalog) {
status[entry.hash] = {};
for (const f of entry.files || []) {
if (!f.variants) continue;
for (const v of f.variants) {
if (f.folder === "diffusion_models" || f.folder === "unet") {
filesToCheck.push({ filename: v.filename, folder: f.folder });
fileMap.push({ entryHash: entry.hash, precision: v.precision });
}
}
}
}
if (!filesToCheck.length) { catalogPrecisionStatus = status; return; }
try {
const checkRes = await fetch("/promptchain/models/check-files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ files: filesToCheck }),
});
const checkData = await checkRes.json();
for (let i = 0; i < (checkData.results || []).length; i++) {
if (checkData.results[i].exists) {
status[fileMap[i].entryHash][fileMap[i].precision] = true;
}
}
} catch {}
catalogPrecisionStatus = status;
}
// ── grouped models ──
let groupedModels = $derived.by(() => {
// Sort models alphabetically by display name first
@@ -247,7 +219,10 @@
const terms = q ? q.split(/\s+/) : [];
return catalog.filter(c => {
const status = catalogPrecisionStatus[c.hash] || {};
if (!Object.values(status).some(v => v)) return false;
// In the precision-tag strip when at least one precision has files on disk
// (installed, or a resumable partial). All-missing entries drop to the
// grouped "available for download" list below.
if (!Object.values(status).some(v => v === "installed" || v === "partial")) return false;
if (!terms.length) return true;
const text = [c.display_name, c.model_name, c.architecture, c.family, c.version].join(" ").toLowerCase();
return terms.every(t => text.includes(t));
@@ -257,7 +232,7 @@
const q = searchQuery.toLowerCase().trim();
let items = catalog.filter(c => {
const status = catalogPrecisionStatus[c.hash] || {};
return !Object.values(status).some(v => v);
return !Object.values(status).some(v => v === "installed" || v === "partial");
});
if (q) {
const terms = q.split(/\s+/);
@@ -282,10 +257,13 @@
name: entry.model_name || entry.display_name || "",
architecture: entry.architecture || "",
family: entry.family || "",
partial: false,
versions: [],
});
}
groups.get(key).versions.push(entry);
const g = groups.get(key);
g.versions.push(entry);
if (entry.partial) g.partial = true;
}
for (const g of groups.values()) {
g.versions.sort((a, b) => (b.version || "").localeCompare(a.version || "", undefined, { numeric: true }));
@@ -429,16 +407,21 @@
});
}
async function handleCatalogPrecisionClick(entry, prec, installed) {
if (!installed) {
onShowCatalogDownload(entry);
async function handleCatalogPrecisionClick(entry, prec, state) {
// Anything not fully present opens the download modal, which re-checks each
// file (with size) and offers "Download N Missing" — so a partial download
// resumes the missing companion instead of restarting from scratch.
if (state !== "installed") {
onShowCatalogDownload(entry, prec);
return;
}
// Installed: find the local model for this precision and show templates
// Installed: jump to this precision's templates. If the model can't be
// resolved (e.g. files on disk but not yet scanned), fall back to the
// download modal rather than silently closing — that onClose() dead-end is
// exactly what left partial entries looking installed yet unclickable.
const resolved = resolveFilesForPrecision(entry.files, prec);
const diffFile = resolved.find(f => f.folder === "diffusion_models" || f.folder === "unet");
if (!diffFile) return;
const localModel = models.find(m => m.filename === diffFile.filename);
const localModel = diffFile && models.find(m => m.filename === diffFile.filename);
if (localModel && templatesForModel(localModel).length > 0) {
activeSubmenu = {
type: "template",
@@ -446,11 +429,83 @@
displayName: modelSettings[localModel.hash]?.display_name || localModel.filename,
};
activeNestedSubmenu = null;
} else {
onShowCatalogDownload(entry, prec);
}
}
// ── installed-model precision upgrades ──
// An installed model whose config declares several precisions for its
// primary file (e.g. Wan fp8_scaled + fp16) but only has some on disk can be
// "moved up" to a heavier precision. We surface the declared precisions as
// inline tags on the installed row: present ones jump to templates, missing
// ones open the download modal. Detection is client-side — modelSettings
// already ships the config's files, and the models list tells us which
// primary files are on disk.
// The config's primary diffusion/unet file entry — its variants are the
// upgrade options. Mirrors the backend is_primary_model_file: an explicit
// primary:true wins, else the first diffusion/unet entry.
function primaryFileEntry(settings) {
const files = settings?.files || [];
const isPrimaryFolder = f => f.folder === "diffusion_models" || f.folder === "unet" || f.folder === "checkpoints";
return files.find(f => f.primary && isPrimaryFolder(f))
|| files.find(isPrimaryFolder)
|| null;
}
// Declared primary precisions for an installed group, each tagged with
// whether its file is on disk. Empty unless the config has 2+ variants
// (nothing to upgrade otherwise).
function groupPrecisions(group) {
const settings = modelSettings[group.versions[0]?.hash];
const variants = primaryFileEntry(settings)?.variants || [];
if (variants.length < 2) return [];
return variants.map(v => {
const fnameLower = (v.filename || "").toLowerCase();
const localModel = models.find(m => (m.filename || "").toLowerCase() === fnameLower);
return { precision: v.precision, filename: v.filename, installed: !!localModel, model: localModel || null };
});
}
// Catalog-entry shape for the download modal, built from an installed model's
// stored config so the same modal handles a precision upgrade.
function entryForGroup(group) {
const s = modelSettings[group.versions[0]?.hash] || {};
const entry = {
hash: group.versions[0]?.hash,
display_name: s.display_name || group.name,
model_name: s.model_name || group.name,
version: s.version || "",
architecture: s.architecture || group.architecture || "",
family: s.family || group.family || "",
description: s.description || "",
files: s.files || [],
};
if (s.civitai_model_id) entry.civitai_model_id = s.civitai_model_id;
if (s.thumbnail) entry.thumbnail = s.thumbnail;
return entry;
}
// Installed precision tag → that precision's templates.
function handleInstalledPrecisionClick(prec) {
if (prec.model && templatesForModel(prec.model).length > 0) {
activeNestedSubmenu = null;
activeSubmenu = {
type: "template",
model: prec.model,
displayName: modelSettings[prec.model.hash]?.display_name || prec.model.filename,
};
} else {
onClose();
}
}
// Missing precision tag → download modal pre-set to that precision.
function handleMissingPrecisionClick(group, prec) {
onShowCatalogDownload(entryForGroup(group), prec.precision);
}
function sortCategories(cats) {
const known = (categoryOrder || []).filter(c => cats.includes(c));
const unknown = cats.filter(c => !categoryOrder?.includes(c)).sort();
@@ -464,26 +519,48 @@
const version = settings?.version || "";
const filename = (model.filename || "").toLowerCase();
// No separator requirement — civitai filenames embed precision in
// camelCase (v4BaseFp8) which lowercasing flattens to "basefp8".
// Prefer the precision the config DECLARES for this exact file variant.
// A filename regex collapses GGUF quants (Q4/Q5/Q8 all read "GGUF") and
// fp8_scaled vs fp8, so multiple installed precisions of one model would
// render as identical, indistinguishable rows. The variant's precision
// field disambiguates them. Fall back to a filename sniff for configs
// with no variants array.
const precision = declaredPrecision(settings, filename) || sniffPrecision(filename);
if (precision && version) {
const esc = precision.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (version.toLowerCase().includes(precision.toLowerCase())) {
return version.replace(new RegExp(`\\s*${esc}`, "i"), ` (${precision})`);
}
return `${version} (${precision})`;
}
return version || model.filename || "";
}
// The precision label a config declares for the file with this exact name,
// checking each file entry's flat filename and its precision variants.
function declaredPrecision(settings, filenameLower) {
for (const f of settings?.files || []) {
if ((f.filename || "").toLowerCase() === filenameLower) return f.precision || null;
for (const v of f.variants || []) {
if ((v.filename || "").toLowerCase() === filenameLower) return v.precision || null;
}
}
return null;
}
// No separator requirement — civitai filenames embed precision in
// camelCase (v4BaseFp8) which lowercasing flattens to "basefp8".
function sniffPrecision(filenameLower) {
const precisionPatterns = [
[/bf16/i, "BF16"], [/fp16/i, "FP16"], [/fp8/i, "FP8"],
[/fp32/i, "FP32"], [/nvfp4/i, "NVFP4"], [/fp4/i, "FP4"],
[/\.gguf$/i, "GGUF"],
];
let precision = null;
for (const [pattern, label] of precisionPatterns) {
if (pattern.test(filename)) { precision = label; break; }
if (pattern.test(filenameLower)) return label;
}
if (precision && version) {
if (version.toLowerCase().includes(precision.toLowerCase())) {
return version.replace(new RegExp(`\\s*${precision}`, "i"), ` (${precision})`);
}
return `${version} (${precision})`;
}
return version || model.filename || "";
return null;
}
// Catalog entries have no modelSettings[hash], so derive the version label
@@ -683,6 +760,7 @@
{@const model = group.versions[0]}
{@const displayName = group.name}
{@const meta = [group.architecture, group.family].filter(Boolean).join(" · ")}
{@const precs = groupPrecisions(group)}
<div class="pcr-picker-item"
class:pcr-picker-item-active={activeSubmenu?.type === "version" && activeSubmenu?.group?.name === group.name
|| activeSubmenu?.type === "template" && activeSubmenu?.model?.hash === model.hash}
@@ -693,42 +771,63 @@
<div class="pcr-picker-item-info">
<span class="pcr-picker-item-name">
{displayName}
{#if group.versions.length > 1}
{#if group.versions.length > 1 && !precs.length}
<span class="pcr-model-version-badge">{group.versions.length}</span>
{/if}
</span>
{#if meta}
<span class="pcr-picker-item-meta">{meta}</span>
{/if}
{#if precs.length}
<div class="pcr-picker-precision-row">
{#each precs as p}
<span class="pcr-picker-precision-tag"
class:pcr-precision-installed={p.installed}
class:pcr-precision-missing={!p.installed}
title={p.installed ? `${p.precision.toUpperCase()} installed — use it` : `Download ${p.precision.toUpperCase()}`}
onclick={(e) => {
e.stopPropagation();
p.installed ? handleInstalledPrecisionClick(p) : handleMissingPrecisionClick(group, p);
}}>
{p.precision.toUpperCase()}{p.installed ? "" : " ↓"}
</span>
{/each}
</div>
{/if}
</div>
<span class="pcr-picker-item-arrow">▸</span>
</div>
{/each}
<!-- Partially installed catalog (with inline precision buttons) -->
<!-- Started catalog entries (installed / resumable-partial precisions) -->
{#each catalogInstalled as entry}
{@const precisions = extractPrecisions(entry.files || [])}
{@const status = catalogPrecisionStatus[entry.hash] || {}}
<div class="pcr-picker-item pcr-picker-catalog-item"
class:pcr-picker-catalog-partial={precisions.some(p => status[p] === "partial")}
onclick={(e) => {
e.stopPropagation();
const installedPrec = precisions.find(p => status[p]);
if (installedPrec) handleCatalogPrecisionClick(entry, installedPrec, true);
const prec = precisions.find(p => status[p] === "installed")
|| precisions.find(p => status[p] === "partial");
if (prec) handleCatalogPrecisionClick(entry, prec, status[prec]);
}}>
<div class="pcr-picker-item-info">
<span class="pcr-picker-item-name">{entry.display_name || entry.model_name}</span>
<div class="pcr-picker-precision-row">
{#each precisions as prec}
{@const installed = !!status[prec]}
{@const st = status[prec] || "missing"}
<span class="pcr-picker-precision-tag"
class:pcr-precision-installed={installed}
class:pcr-precision-missing={!installed}
title={installed ? `${prec.toUpperCase()} installed` : `Download ${prec.toUpperCase()}`}
class:pcr-precision-installed={st === "installed"}
class:pcr-precision-partial={st === "partial"}
class:pcr-precision-missing={st === "missing"}
title={st === "installed" ? `${prec.toUpperCase()} installed`
: st === "partial" ? `${prec.toUpperCase()} download unfinished — click to resume`
: `Download ${prec.toUpperCase()}`}
onclick={(e) => {
e.stopPropagation();
handleCatalogPrecisionClick(entry, prec, installed);
handleCatalogPrecisionClick(entry, prec, st);
}}>
{prec.toUpperCase()}{installed ? "" : " ↓"}
{prec.toUpperCase()}{st === "installed" ? "" : st === "partial" ? " ↻" : " ↓"}
</span>
{/each}
</div>
@@ -744,6 +843,7 @@
{@const meta = [group.architecture, group.family].filter(Boolean).join(" · ")}
<div class="pcr-picker-item pcr-picker-catalog-item"
class:pcr-picker-item-active={activeSubmenu?.type === "catalogVersion" && activeSubmenu?.group === group}
class:pcr-picker-catalog-partial={group.partial}
onclick={(e) => { e.stopPropagation(); handleCatalogGroupClick(group); }}>
<div class="pcr-picker-item-info">
<span class="pcr-picker-item-name">
@@ -752,11 +852,13 @@
<span class="pcr-model-version-badge">{group.versions.length}</span>
{/if}
</span>
{#if meta}
<span class="pcr-picker-item-meta">{meta}</span>
{#if meta || group.partial}
<span class="pcr-picker-item-meta">
{meta}{#if group.partial}{meta ? " · " : ""}<span class="pcr-picker-resume-hint">resume ↻</span>{/if}
</span>
{/if}
</div>
<span class="pcr-picker-item-arrow">{group.versions.length > 1 ? "▸" : "↓"}</span>
<span class="pcr-picker-item-arrow">{group.versions.length > 1 ? "▸" : (group.partial ? "↻" : "↓")}</span>
</div>
{/each}
{/if}
@@ -1103,6 +1205,14 @@
.pcr-precision-installed:hover {
background: rgba(79, 195, 247, 0.25);
}
.pcr-precision-partial {
background: rgba(255, 183, 77, 0.15);
color: #ffb74d;
border: 1px solid rgba(255, 183, 77, 0.4);
}
.pcr-precision-partial:hover {
background: rgba(255, 183, 77, 0.28);
}
.pcr-precision-missing {
background: rgba(255, 255, 255, 0.05);
color: #888;
@@ -1112,6 +1222,14 @@
color: #ccc;
border-color: #666;
}
/* a started-but-unfinished download gets an amber edge so it reads as
"resume", not "ready" */
.pcr-picker-catalog-partial {
border-left-color: #ffb74d;
}
.pcr-picker-resume-hint {
color: #ffb74d;
}
.pcr-picker-civitai-loading {
padding: 10px 12px;
+13 -1
View File
@@ -130,6 +130,18 @@
next.delete(itemPath);
flashingThumbs = next;
}
// A cover whose /thumb request raced the record commit latches broken with no
// recovery. Cache-bust and re-fetch on the error event — bounded, no polling.
function retryThumb(e) {
const img = e.currentTarget;
const n = +img.dataset.pcrRetry || 0;
if (n >= 3) return;
img.dataset.pcrRetry = String(n + 1);
const u = new URL(img.src, location.href);
u.searchParams.set("r", String(n + 1));
img.src = u.toString();
}
</script>
<div class="pcr-gv" bind:this={wrapEl}>
@@ -183,7 +195,7 @@
{/if}
{:else if item.type === "workflow" && item.thumbnailHash}
<img src={thumbSrc(item)} alt={item.name} loading="lazy" draggable="false"
onload={() => onThumbUpdate(item)} />
onload={() => onThumbUpdate(item)} onerror={retryThumb} />
{#if flashingThumbs.has(item.path)}
<div class="pcr-ji-flash" onanimationend={() => onFlashEnd(item.path)}></div>
{/if}
@@ -24,6 +24,18 @@
thumbLoaded = true;
}
// A cover whose /thumb request raced the record commit latches broken with no
// recovery. Cache-bust and re-fetch on the error event — bounded, no polling.
function retryThumb(e) {
const img = e.currentTarget;
const n = +img.dataset.pcrRetry || 0;
if (n >= 3) return;
img.dataset.pcrRetry = String(n + 1);
const u = new URL(img.src, location.href);
u.searchParams.set("r", String(n + 1));
img.src = u.toString();
}
function handleDragOver(e) {
if (item.type !== "folder") return;
if (!e.dataTransfer.types.includes("application/x-promptchain-move")) return;
@@ -81,6 +93,7 @@
src={apiURL(`/promptchain/thumb/${item.thumbnailHash}`)}
alt={item.name} loading="lazy" draggable="false"
onload={onThumbUpdate}
onerror={retryThumb}
/>
{#if showFlash}
<div class="pcr-gi-flash" onanimationend={() => showFlash = false}></div>
@@ -27,6 +27,18 @@
return (b / 1048576).toFixed(1) + " MB";
}
// A cover whose /thumb request raced the record commit latches broken with no
// recovery. Cache-bust and re-fetch on the error event — bounded, no polling.
function retryThumb(e) {
const img = e.currentTarget;
const n = +img.dataset.pcrRetry || 0;
if (n >= 3) return;
img.dataset.pcrRetry = String(n + 1);
const u = new URL(img.src, location.href);
u.searchParams.set("r", String(n + 1));
img.src = u.toString();
}
function fmtDate(ts) {
if (!ts) return "-";
return new Date(ts * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" });
@@ -122,6 +134,7 @@
class="pcr-lv-mini"
src={apiURL(`/promptchain/thumb/${item.thumbnailHash}`)}
alt="" loading="lazy"
onerror={retryThumb}
/>
{:else if item.type === "workflow"}
<svg class="pcr-lv-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">

Some files were not shown because too many files have changed in this diff Show More