1.6.7
This commit is contained in:
+1
-2
@@ -2,8 +2,7 @@
|
|||||||
/XTRA
|
/XTRA
|
||||||
/__pycache__
|
/__pycache__
|
||||||
/.idea
|
/.idea
|
||||||
/js
|
|
||||||
/TermList*.json
|
/TermList*.json
|
||||||
/___backup.pyw
|
/___backup.pyw
|
||||||
/load_image_from_dir.py
|
|
||||||
/ComfyUI-noEmbryo.iml
|
/ComfyUI-noEmbryo.iml
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ You can access them through "Add node > noEmbryo" submenu.
|
|||||||
---
|
---
|
||||||
## Json Prompt Loader
|
## Json Prompt Loader
|
||||||

|

|
||||||
|
|
||||||
A node that can load a `.json` file with `item:prompt` pairs and outputs the selected item's prompt, while combining it with a custom prompt.
|
A node that can load a `.json` file with `item:prompt` pairs and outputs the selected item's prompt, while combining it with a custom prompt.
|
||||||
It can load `.json` files from any directory, not just the node's directory.
|
It can load `.json` files from any directory, not just the node's directory.
|
||||||
For the custom text integration, there is a variable (can be specified by the user), that can be used in the item's prompt text to insert the custom text anywhere in the body of the prompt.
|
For the custom text integration, there is a variable (can be specified by the user), that can be used in the item's prompt text to insert the custom text anywhere in the body of the prompt.
|
||||||
@@ -117,7 +116,6 @@ A node that "chops" a text using a regular expression and outputs the chopped pa
|
|||||||
## H3 Motion Context Clip Stitcher
|
## H3 Motion Context Clip Stitcher
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Final assembly for [NikoDemon80's H3 Motion Context](https://github.com/NikoDemon80/ComfyUI-H3-Motion-Context) AV clip archives.
|
Final assembly for [NikoDemon80's H3 Motion Context](https://github.com/NikoDemon80/ComfyUI-H3-Motion-Context) AV clip archives.
|
||||||
|
|
||||||
It loads numbered h3_motion_context_av_v1 files (clip_xx.safetensors), decodes one clip at a time to avoid memory peaks, dissolves the overlap between adjacent clips (video + synchronized audio), and concatenates them to a final video and audio stream.
|
It loads numbered h3_motion_context_av_v1 files (clip_xx.safetensors), decodes one clip at a time to avoid memory peaks, dissolves the overlap between adjacent clips (video + synchronized audio), and concatenates them to a final video and audio stream.
|
||||||
@@ -148,7 +146,7 @@ No quality loss, like when trying to concatenate encoded videos.
|
|||||||
---
|
---
|
||||||
## H3 Motion Context Clip Purge
|
## H3 Motion Context Clip Purge
|
||||||

|

|
||||||
Deletes the numbered `h3_motion_context_av_v1` clip archive files at the root of a folder (default: `h3_context`)
|
Deletes the numbered `h3_motion_context_av_v1` clip archive files at the root of a folder (default: `h3_context`).
|
||||||
Only files matching the pattern are removed; sub-folders and everything inside them are left untouched.
|
Only files matching the pattern are removed; sub-folders and everything inside them are left untouched.
|
||||||
|
|
||||||
- **Controls**
|
- **Controls**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
@@ -12,7 +13,6 @@ from urllib.error import URLError
|
|||||||
from PIL import (Image, ImageOps, ImageSequence, ImageFile, UnidentifiedImageError, )
|
from PIL import (Image, ImageOps, ImageSequence, ImageFile, UnidentifiedImageError, )
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
import folder_paths
|
import folder_paths
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from server import PromptServer
|
from server import PromptServer
|
||||||
@@ -94,9 +94,13 @@ def _pillow(fn, arg):
|
|||||||
return x
|
return x
|
||||||
|
|
||||||
|
|
||||||
def _pil_to_image_mask(img: 'Image.Image | Iterable[Image.Image]',
|
def _pil_to_image_mask(img, output_image, output_mask):
|
||||||
output_image: 'list[torch.Tensor] | None',
|
"""
|
||||||
output_mask: 'list[torch.Tensor] | None'):
|
|
||||||
|
:type img: Image.Image | Iterable[Image.Image]
|
||||||
|
:type output_image: list[torch.Tensor] | None
|
||||||
|
:type output_mask: list[torch.Tensor] | None
|
||||||
|
"""
|
||||||
output_images = []
|
output_images = []
|
||||||
output_masks = []
|
output_masks = []
|
||||||
w, h = None, None
|
w, h = None, None
|
||||||
@@ -278,7 +282,7 @@ class LoadImageFromPathEnhanced:
|
|||||||
" and height are connected, when set (not 0), and"
|
" and height are connected, when set (not 0), and"
|
||||||
" it overrides max_megapixels.", }), }, }
|
" it overrides max_megapixels.", }), }, }
|
||||||
|
|
||||||
CATEGORY = "noEmbryo"
|
CATEGORY = "noEmbryo/Image"
|
||||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
|
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
|
||||||
RETURN_NAMES = ("IMAGE", "MASK", "path")
|
RETURN_NAMES = ("IMAGE", "MASK", "path")
|
||||||
FUNCTION = "load_image_enhanced"
|
FUNCTION = "load_image_enhanced"
|
||||||
@@ -449,6 +453,304 @@ class LoadImageFromPathEnhanced:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ImageComposer — compose several IMAGE inputs into one sheet.
|
||||||
|
# Natural sizing only: one shared scale factor (never above 1), skyline
|
||||||
|
# packing, tightest arrangement. The packing is mirrored in JS
|
||||||
|
# (web/js/image_nodes.js) for the live on-node preview.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_EPS = 1e-9
|
||||||
|
_ALIGN = 16
|
||||||
|
_IC_BACKGROUNDS = {"black": 0.0, "grey": 0.5, "white": 1.0}
|
||||||
|
_IC_PACK_ASPECT_MIN = 0.45
|
||||||
|
_IC_PACK_ASPECT_MAX = 2.2
|
||||||
|
_IC_PACK_WIDTH_STEPS = 48
|
||||||
|
_IC_MAX_IMAGES = 16
|
||||||
|
|
||||||
|
|
||||||
|
def _ic_skyline_pack(sizes, width, gap):
|
||||||
|
""" Place rectangles bottom-left into a strip `width` wide.
|
||||||
|
|
||||||
|
Returns (placements, w0, h0) in source pixels, or None if anything
|
||||||
|
does not fit. Placements are (x, y, w, h), in the order given.
|
||||||
|
Nothing is ever rotated.
|
||||||
|
"""
|
||||||
|
sky = [(0.0, width, 0.0)]
|
||||||
|
placed = []
|
||||||
|
for w, h in sizes:
|
||||||
|
iw = w + gap
|
||||||
|
ih = h + gap
|
||||||
|
if iw > width + _EPS:
|
||||||
|
return None
|
||||||
|
best = None
|
||||||
|
for i in range(len(sky)):
|
||||||
|
start = sky[i][0]
|
||||||
|
if start + iw > width + _EPS:
|
||||||
|
continue
|
||||||
|
y = 0.0
|
||||||
|
span = iw
|
||||||
|
j = i
|
||||||
|
while span > _EPS and j < len(sky):
|
||||||
|
if sky[j][2] > y:
|
||||||
|
y = sky[j][2]
|
||||||
|
span -= sky[j][1]
|
||||||
|
j += 1
|
||||||
|
if span > _EPS:
|
||||||
|
continue # ran off the right-hand end
|
||||||
|
if best is None or (y, start) < best:
|
||||||
|
best = (y, start)
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
y, x = best
|
||||||
|
placed.append((x, y, w, h))
|
||||||
|
# Cut the covered span out of the skyline and lay the new top
|
||||||
|
# over it, then merge neighbours at the same height.
|
||||||
|
cut = []
|
||||||
|
end = x + iw
|
||||||
|
for sx, sw, sy in sky:
|
||||||
|
if sx + sw <= x + _EPS or sx >= end - _EPS:
|
||||||
|
cut.append((sx, sw, sy))
|
||||||
|
continue
|
||||||
|
if sx < x:
|
||||||
|
cut.append((sx, x - sx, sy))
|
||||||
|
if sx + sw > end:
|
||||||
|
cut.append((end, sx + sw - end, sy))
|
||||||
|
cut.append((x, iw, y + ih))
|
||||||
|
cut.sort(key=lambda seg_: seg_[0])
|
||||||
|
merged = []
|
||||||
|
for seg in cut:
|
||||||
|
if merged and abs(merged[-1][2] - seg[2]) < _EPS:
|
||||||
|
merged[-1] = (merged[-1][0], merged[-1][1] + seg[1], seg[2])
|
||||||
|
else:
|
||||||
|
merged.append(seg)
|
||||||
|
sky = merged
|
||||||
|
|
||||||
|
w0 = max(p[0] + p[2] for p in placed)
|
||||||
|
h0 = max(p[1] + p[3] for p in placed)
|
||||||
|
return placed, w0, h0
|
||||||
|
|
||||||
|
|
||||||
|
def _ic_q(v):
|
||||||
|
""" Quantise a score for comparison — mirrors the JS round-trip.
|
||||||
|
"""
|
||||||
|
return int(math.floor(v * 1e9 + 0.5))
|
||||||
|
|
||||||
|
|
||||||
|
def _ic_pack_sweep(sizes, gap):
|
||||||
|
""" Best packing over candidate widths and placement orders.
|
||||||
|
|
||||||
|
Returns (placements, w0, h0) in source pixels, or None.
|
||||||
|
"""
|
||||||
|
used = sum(w * h for w, h in sizes)
|
||||||
|
lo = max(w for w, h in sizes) + gap
|
||||||
|
hi = sum(w for w, h in sizes) + gap * len(sizes)
|
||||||
|
orders = [
|
||||||
|
list(range(len(sizes))),
|
||||||
|
sorted(range(len(sizes)), key=lambda i: (-sizes[i][1], i)),
|
||||||
|
sorted(range(len(sizes)), key=lambda i: (-sizes[i][0], i)),
|
||||||
|
sorted(range(len(sizes)), key=lambda i: (-sizes[i][0] * sizes[i][1], i)),
|
||||||
|
]
|
||||||
|
found = None
|
||||||
|
for order in orders:
|
||||||
|
ordered = [sizes[i] for i in order]
|
||||||
|
best = None
|
||||||
|
for step in range(_IC_PACK_WIDTH_STEPS):
|
||||||
|
width = lo + (hi - lo) * step / (_IC_PACK_WIDTH_STEPS - 1)
|
||||||
|
got = _ic_skyline_pack(ordered, width, gap)
|
||||||
|
if got is None:
|
||||||
|
continue
|
||||||
|
placed, w0, h0 = got
|
||||||
|
fill = used / float(w0 * h0)
|
||||||
|
aspect = w0 / h0
|
||||||
|
if not _IC_PACK_ASPECT_MIN <= aspect <= _IC_PACK_ASPECT_MAX:
|
||||||
|
continue
|
||||||
|
# Tightest wins; ties go to the squarer sheet, then wider.
|
||||||
|
key = (-_ic_q(fill), _ic_q(abs(math.log(aspect))), -_ic_q(aspect))
|
||||||
|
if best is None or key < best[0]:
|
||||||
|
best = (key, fill, placed, w0, h0, order)
|
||||||
|
if best is not None and (found is None or best[0] < found[0]):
|
||||||
|
found = best
|
||||||
|
if found is None:
|
||||||
|
return None
|
||||||
|
_, _fill, placed, w0, h0, order = found
|
||||||
|
boxes = [None] * len(sizes)
|
||||||
|
for slot, (x, y, w, h) in zip(order, placed):
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
boxes[slot] = (x, y, w, h)
|
||||||
|
return boxes, w0, h0
|
||||||
|
|
||||||
|
|
||||||
|
def _ic_align_down(v):
|
||||||
|
return max(_ALIGN, int(v // _ALIGN) * _ALIGN)
|
||||||
|
|
||||||
|
|
||||||
|
def _ic_align_up(v):
|
||||||
|
return max(_ALIGN, int(math.ceil(v / float(_ALIGN))) * _ALIGN)
|
||||||
|
|
||||||
|
|
||||||
|
def _ic_box(x, y, w, h, width, height):
|
||||||
|
""" One integer box: SIZE rounded once, position rounded and clamped. """
|
||||||
|
bw = max(1, min(width, int(math.floor(w + 0.5))))
|
||||||
|
bh = max(1, min(height, int(math.floor(h + 0.5))))
|
||||||
|
x0 = max(0, min(width - bw, int(math.floor(x + 0.5))))
|
||||||
|
y0 = max(0, min(height - bh, int(math.floor(y + 0.5))))
|
||||||
|
return x0, y0, bw, bh
|
||||||
|
|
||||||
|
|
||||||
|
def _ic_plan_natural(sizes, budget, gap):
|
||||||
|
""" Plan a natural-sizing sheet.
|
||||||
|
|
||||||
|
`sizes` is [(w, h), ...] in source pixels; `budget` the pixel budget
|
||||||
|
(math.inf for no cap). Returns {"width", "height", "boxes"} with
|
||||||
|
boxes as integer (x, y, w, h) in canvas pixels, or None.
|
||||||
|
A frame of gap/2 is left around the whole sheet, matching the visual
|
||||||
|
weight of the inter-layer gaps.
|
||||||
|
"""
|
||||||
|
found = _ic_pack_sweep(sizes, gap)
|
||||||
|
if found is None:
|
||||||
|
return None
|
||||||
|
boxes, w0, h0 = found
|
||||||
|
frame = int(round(gap / 2.0)) # half-gap frame; 0 when gap is 0
|
||||||
|
if budget != math.inf:
|
||||||
|
budget = max(1.0, budget - 4 * frame * frame)
|
||||||
|
s_exact = min(1.0, math.sqrt(budget / float(w0 * h0)))
|
||||||
|
if s_exact >= 1.0 and _ic_align_up(w0) * _ic_align_up(h0) <= budget:
|
||||||
|
width = _ic_align_up(w0)
|
||||||
|
height = _ic_align_up(h0)
|
||||||
|
scale = 1.0
|
||||||
|
else:
|
||||||
|
width = _ic_align_down(s_exact * w0)
|
||||||
|
height = max(_ALIGN, int(math.floor(
|
||||||
|
(h0 * width / float(w0)) / _ALIGN + 0.5)) * _ALIGN)
|
||||||
|
scale = min(width / float(w0), height / float(h0), 1.0)
|
||||||
|
ox = (width - w0 * scale) / 2.0
|
||||||
|
oy = (height - h0 * scale) / 2.0
|
||||||
|
out = []
|
||||||
|
for x, y, w, h in boxes:
|
||||||
|
out.append(_ic_box(ox + x * scale, oy + y * scale, w * scale,
|
||||||
|
h * scale, width, height))
|
||||||
|
# Expand the canvas by the frame and shift every box inward by it.
|
||||||
|
width += 2 * frame
|
||||||
|
height += 2 * frame
|
||||||
|
out = [(x + frame, y + frame, w, h) for x, y, w, h in out]
|
||||||
|
return {"width": width, "height": height, "boxes": out}
|
||||||
|
|
||||||
|
|
||||||
|
class ImageComposer:
|
||||||
|
""" Compose multiple IMAGE inputs into one sheet, natural sizing. """
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
optional = {}
|
||||||
|
for i in range(1, _IC_MAX_IMAGES + 1):
|
||||||
|
optional[f"image{i}"] = ("IMAGE", {"tooltip":
|
||||||
|
"Image layer — connect another Load Image node to reveal "
|
||||||
|
"the next input slot."})
|
||||||
|
return {"required": {
|
||||||
|
"gap": ("INT", {"default": 0, "min": 0, "max": 256, "step": 2,
|
||||||
|
"tooltip": "Pixels of background between layers."}),
|
||||||
|
"background": (list(_IC_BACKGROUNDS), {"default": "black",
|
||||||
|
"tooltip": "Colour behind the layers."}),
|
||||||
|
"max_megapixels": ("FLOAT", {"default": 0.0,
|
||||||
|
"min": 0.0, "max": 128.0, "step": 0.01,
|
||||||
|
"tooltip": "Cap the sheet size (1.0 = 1024x1024 px). "
|
||||||
|
"0 = no cap."}),
|
||||||
|
},
|
||||||
|
"optional": optional}
|
||||||
|
|
||||||
|
CATEGORY = "noEmbryo/Image"
|
||||||
|
RETURN_TYPES = ("IMAGE",)
|
||||||
|
RETURN_NAMES = ("IMAGE",)
|
||||||
|
FUNCTION = "compose"
|
||||||
|
DESCRIPTION = (
|
||||||
|
" Compose several images into ONE image. Images keep their order "
|
||||||
|
" and relative pixel sizes (natural sizing, never enlarged) and are "
|
||||||
|
" packed as tightly as possible; rows are chosen automatically. "
|
||||||
|
" The preview refreshes instantly when an upstream image, crop, "
|
||||||
|
" rotation or megapixel cap changes — no workflow run needed.")
|
||||||
|
|
||||||
|
# noinspection PyMethodMayBeStatic
|
||||||
|
def compose(self, gap=8, background="black",
|
||||||
|
max_megapixels=0.0, **kwargs):
|
||||||
|
# Collect connected images, in input order.
|
||||||
|
# KJNodes Set/Get nodes pass IMAGE tensors through graph links.
|
||||||
|
# If they arrive as lists (e.g. after JSON round-trip), convert them.
|
||||||
|
tiles = []
|
||||||
|
for i in range(1, _IC_MAX_IMAGES + 1):
|
||||||
|
t = kwargs.get(f"image{i}")
|
||||||
|
if t is not None:
|
||||||
|
if not isinstance(t, torch.Tensor):
|
||||||
|
# Handle string (JSON-encoded tensor), dict-wrapped, lists
|
||||||
|
if isinstance(t, str):
|
||||||
|
try:
|
||||||
|
t = json.loads(t)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
continue
|
||||||
|
if isinstance(t, dict):
|
||||||
|
t = t.get("image") or t.get("value") or t.get("data")
|
||||||
|
# noinspection PyBroadException
|
||||||
|
try:
|
||||||
|
t = torch.tensor(t, dtype=torch.float32)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
tiles.append(t[0] if t.dim() == 4 else t) # (H, W, C)
|
||||||
|
if not tiles:
|
||||||
|
raise ValueError("ImageComposer: no images connected. Connect at "
|
||||||
|
"least one image input.")
|
||||||
|
|
||||||
|
gap = max(0, int(gap))
|
||||||
|
try:
|
||||||
|
mp = float(max_megapixels)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
mp = 0.0
|
||||||
|
budget = max(1.0, mp * 1024.0 * 1024.0) if mp > 0 else math.inf
|
||||||
|
|
||||||
|
sizes = [(int(t.shape[1]), int(t.shape[0])) for t in tiles]
|
||||||
|
plan = _ic_plan_natural(sizes, budget, gap)
|
||||||
|
if plan is None:
|
||||||
|
raise ValueError("ImageComposer: could not find a layout.")
|
||||||
|
|
||||||
|
width, height = plan["width"], plan["height"]
|
||||||
|
fill = _IC_BACKGROUNDS.get(background, 0.0)
|
||||||
|
canvas = torch.full((1, height, width, 3), fill, dtype=torch.float32)
|
||||||
|
|
||||||
|
for idx, (tile, (x, y, w, h)) in enumerate(zip(tiles, plan["boxes"])):
|
||||||
|
th, tw = int(tile.shape[0]), int(tile.shape[1])
|
||||||
|
# Fit the tile inside its slot, centered, never enlarging.
|
||||||
|
scale = min(w / float(tw), h / float(th), 1.0)
|
||||||
|
nw, nh = max(1, min(w, round(tw * scale))), max(1, min(h, round(th * scale)))
|
||||||
|
px = x + (w - nw) // 2
|
||||||
|
py = y + (h - nh) // 2
|
||||||
|
tile = tile.permute(2, 0, 1).unsqueeze(0) # (1, C, H, W)
|
||||||
|
scaled = torch.nn.functional.interpolate(
|
||||||
|
tile, size=(nh, nw), mode="bilinear",
|
||||||
|
antialias=True).squeeze(0).permute(1, 2, 0) # (H, W, C)
|
||||||
|
canvas[:, py:py + nh, px:px + nw, :] = scaled.clamp(0.0, 1.0)
|
||||||
|
|
||||||
|
return (canvas,)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def IS_CHANGED(cls, gap=8, background="black",
|
||||||
|
max_megapixels=0.0, **kwargs):
|
||||||
|
m = hashlib.sha256()
|
||||||
|
m.update(str(gap).encode("utf-8"))
|
||||||
|
m.update(str(background).encode("utf-8"))
|
||||||
|
m.update(str(max_megapixels).encode("utf-8"))
|
||||||
|
for i in range(1, _IC_MAX_IMAGES + 1):
|
||||||
|
t = kwargs.get(f"image{i}")
|
||||||
|
if t is not None:
|
||||||
|
if not isinstance(t, torch.Tensor):
|
||||||
|
t = torch.tensor(t, dtype=torch.float32)
|
||||||
|
m.update(str(t.shape).encode("utf-8"))
|
||||||
|
return m.digest().hex()
|
||||||
|
|
||||||
|
# noinspection PyUnusedLocal
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, **_):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# Middleware to handle clipspace file resolution
|
# Middleware to handle clipspace file resolution
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def clipspace_resolver_middleware(request, handler):
|
async def clipspace_resolver_middleware(request, handler):
|
||||||
+1310
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,14 @@
|
|||||||
import os, re, io
|
import os, re, io
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from os.path import realpath, join, dirname, isabs, splitext, basename
|
from os.path import realpath, join, dirname, isabs, splitext, basename
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import folder_paths
|
import folder_paths
|
||||||
from .load_image_from_path import LoadImageFromPathEnhanced
|
from .image_nodes import LoadImageFromPathEnhanced, ImageComposer
|
||||||
from .stitcher import (H3MotionContextClipStitcher, H3ContextLatentConverter,
|
from .minimax import (H3MotionContextClipStitcher, H3ClipRefiner,
|
||||||
H3MotionContextClipPurge)
|
H3ContextLatentConverter,
|
||||||
|
H3MotionContextClipPurge, H3AVLatentFromVideo)
|
||||||
|
|
||||||
MANIFEST = {"name": "noEmbryo Nodes",
|
MANIFEST = {"name": "noEmbryo Nodes",
|
||||||
"version": (1, 6, 6),
|
"version": (1, 6, 6),
|
||||||
@@ -77,7 +80,7 @@ class JsonPromptLoader:
|
|||||||
RETURN_TYPES = ("STRING",)
|
RETURN_TYPES = ("STRING",)
|
||||||
RETURN_NAMES = ("Prompt",)
|
RETURN_NAMES = ("Prompt",)
|
||||||
FUNCTION = "run"
|
FUNCTION = "run"
|
||||||
CATEGORY = "noEmbryo"
|
CATEGORY = "noEmbryo/Prompt"
|
||||||
|
|
||||||
def run(self, json_path, selected_item, variable, custom_prompt):
|
def run(self, json_path, selected_item, variable, custom_prompt):
|
||||||
self.load_data(json_path)
|
self.load_data(json_path)
|
||||||
@@ -244,7 +247,7 @@ class PromptTermList:
|
|||||||
RETURN_TYPES = ("STRING",)
|
RETURN_TYPES = ("STRING",)
|
||||||
RETURN_NAMES = ("Term",)
|
RETURN_NAMES = ("Term",)
|
||||||
# OUTPUT_NODE = True
|
# OUTPUT_NODE = True
|
||||||
CATEGORY = "noEmbryo/Term Nodes"
|
CATEGORY = "noEmbryo/Prompt/Term Nodes"
|
||||||
FUNCTION = "run"
|
FUNCTION = "run"
|
||||||
|
|
||||||
def run(self, terms, strength, store_input, text=None):
|
def run(self, terms, strength, store_input, text=None):
|
||||||
@@ -476,14 +479,216 @@ class AutoSaveWorkflow:
|
|||||||
return (status,)
|
return (status,)
|
||||||
|
|
||||||
|
|
||||||
|
class ReplaceAudioNoReEncode:
|
||||||
|
""" A minimal ComfyUI custom node that replaces the audio stream of an existing
|
||||||
|
video file with a new audio track, using ffmpeg's stream-copy mode for the
|
||||||
|
video (`-c:v copy`). The video bitstream is remuxed losslessly and is never
|
||||||
|
decoded/re-encoded — only the container is rewritten with a new audio stream.
|
||||||
|
|
||||||
|
Requires ffmpeg to be installed and available on PATH.
|
||||||
|
|
||||||
|
video_path : path to an existing encoded video file (e.g. output of
|
||||||
|
VHS Video Combine, or any .mp4/.mov/.mkv on disk).
|
||||||
|
audio : standard ComfyUI AUDIO type ({"waveform": tensor, "sample_rate": int}),
|
||||||
|
e.g. from Load Audio, VHS audio output, or a generated audio node.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"video_path": ("STRING", {"default": "", "multiline": False,
|
||||||
|
"tooltip": "Path to the video file whose audio stream "
|
||||||
|
"will be replaced (e.g. any .mp4/.mov/.mkv on disk)."}),
|
||||||
|
"filename_prefix": ("STRING", {"default": "audio_replaced",
|
||||||
|
"tooltip": "Prefix for the output file name.\n"
|
||||||
|
"The result is saved in the ComfyUI output "
|
||||||
|
"directory as:\n"
|
||||||
|
"<prefix>_<video name>_<counter>.<ext>"}),
|
||||||
|
"audio_codec": (["aac", "copy"], {"default": "aac",
|
||||||
|
"tooltip": "How to encode the new audio stream:\n"
|
||||||
|
"• aac: re-encode to AAC 192kbps (always "
|
||||||
|
"used when the audio comes from the AUDIO "
|
||||||
|
"tensor input)\n"
|
||||||
|
"• copy: remux the audio file losslessly, "
|
||||||
|
"without re-encoding (only meaningful when "
|
||||||
|
"using the audio_path input)"}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"audio": ("AUDIO", {"tooltip": "ComfyUI AUDIO signal (e.g. from Load Audio or a "
|
||||||
|
"generated audio node) to use as the new audio "
|
||||||
|
"stream.\nIgnored if audio_path is set."}),
|
||||||
|
"audio_path": ("STRING", {"default": "", "multiline": False,
|
||||||
|
"tooltip": "Path to an audio file — or a video file, whose "
|
||||||
|
"audio stream will be extracted — to use as the new "
|
||||||
|
"audio stream. If set, it takes priority over the "
|
||||||
|
"audio tensor input."}),
|
||||||
|
"shortest": ("BOOLEAN", {"default": True,
|
||||||
|
"tooltip": "If enabled and the audio is shorter/longer than "
|
||||||
|
"the video, the output is trimmed to the "
|
||||||
|
"shorter of the two streams."}),
|
||||||
|
},
|
||||||
|
"hidden": {
|
||||||
|
"prompt": "PROMPT",
|
||||||
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DESCRIPTION = ("Replaces the audio stream of a video file without re-encoding the video. "
|
||||||
|
"The new audio comes either from an AUDIO tensor input or from an audio file "
|
||||||
|
"given by audio_path. Requires ffmpeg on the PATH.")
|
||||||
|
|
||||||
|
RETURN_TYPES = ("STRING",)
|
||||||
|
RETURN_NAMES = ("video_path",)
|
||||||
|
OUTPUT_TOOLTIPS = ("The path of the output video file with the replaced audio stream.",)
|
||||||
|
FUNCTION = "replace_audio"
|
||||||
|
CATEGORY = "noEmbryo"
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ffm_escape(text):
|
||||||
|
""" Escapes a string for use as a value in an ffmetadata file """
|
||||||
|
for ch in ("\\", "=", ";", "#", "\n"):
|
||||||
|
text = text.replace(ch, "\\" + ch) if ch != "\n" else text.replace(ch, r"\n")
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_wave_file(wave_path, waveform, sample_rate):
|
||||||
|
""" Writes a waveform tensor to a wav file, using only the standard library
|
||||||
|
"""
|
||||||
|
import wave
|
||||||
|
import numpy as np
|
||||||
|
if waveform.dim() == 1: # [samples] -> [1, samples]
|
||||||
|
waveform = waveform.unsqueeze(0)
|
||||||
|
# [channels, samples] -> [samples, channels]
|
||||||
|
samples = waveform.cpu().numpy().T
|
||||||
|
samples = np.clip(samples, -1.0, 1.0)
|
||||||
|
pcm = (samples * 32767.0).astype(np.int16)
|
||||||
|
with wave.open(wave_path, "wb") as wf:
|
||||||
|
wf.setnchannels(pcm.shape[1])
|
||||||
|
wf.setsampwidth(2) # 2 bytes = 16 bit
|
||||||
|
wf.setframerate(sample_rate)
|
||||||
|
wf.writeframes(pcm.tobytes())
|
||||||
|
|
||||||
|
def replace_audio(self, video_path, filename_prefix, audio_codec,
|
||||||
|
audio=None, audio_path="", shortest=True,
|
||||||
|
prompt=None, extra_pnginfo=None):
|
||||||
|
if not video_path or not os.path.isfile(video_path):
|
||||||
|
raise FileNotFoundError(f"Video file not found: {video_path!r}")
|
||||||
|
|
||||||
|
output_dir = folder_paths.get_output_directory()
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
tmp_audio_path = None
|
||||||
|
if audio_path:
|
||||||
|
if not os.path.isfile(audio_path):
|
||||||
|
raise FileNotFoundError(f"Audio file not found: {audio_path!r}")
|
||||||
|
second_input = audio_path
|
||||||
|
elif audio is not None:
|
||||||
|
# --- Write the incoming AUDIO tensor to a temp wav file ---
|
||||||
|
waveform = audio["waveform"]
|
||||||
|
sample_rate = audio["sample_rate"]
|
||||||
|
if waveform.dim() == 3: # [batch, channels, samples] -> take first item
|
||||||
|
waveform = waveform[0]
|
||||||
|
tmp_audio_fd, tmp_audio_path = tempfile.mkstemp(suffix=".wav")
|
||||||
|
os.close(tmp_audio_fd)
|
||||||
|
self.write_wave_file(tmp_audio_path, waveform, sample_rate)
|
||||||
|
second_input = tmp_audio_path
|
||||||
|
# Copying raw PCM into a container makes no sense, so force aac
|
||||||
|
audio_codec = "aac"
|
||||||
|
else:
|
||||||
|
raise ValueError("No audio given: connect an AUDIO input or set audio_path.")
|
||||||
|
|
||||||
|
# --- Build a unique output path ---
|
||||||
|
base_name = os.path.splitext(os.path.basename(video_path))[0]
|
||||||
|
ext = os.path.splitext(video_path)[1] or ".mp4"
|
||||||
|
# Start from max existing number + 1, so deleted files don't cause name reuse.
|
||||||
|
# The prefix may contain subdirectories (e.g. "MMH3\NewAudio"), so the scan
|
||||||
|
# must look in the directory the files are actually written to.
|
||||||
|
out_path = os.path.join(output_dir, f"{filename_prefix}_{base_name}_001{ext}")
|
||||||
|
scan_dir = os.path.dirname(out_path)
|
||||||
|
os.makedirs(scan_dir, exist_ok=True)
|
||||||
|
# listdir() returns bare filenames, so only the last component of the
|
||||||
|
# prefix (without the directory part) can appear in them
|
||||||
|
prefix_name = os.path.basename(filename_prefix.replace("\\", "/"))
|
||||||
|
counter = 1
|
||||||
|
pattern = re.compile(rf"^{re.escape(prefix_name)}_{re.escape(base_name)}"
|
||||||
|
rf"_(\d+){re.escape(ext)}$")
|
||||||
|
for fname in os.listdir(scan_dir):
|
||||||
|
m = pattern.match(fname)
|
||||||
|
if m:
|
||||||
|
counter = max(counter, int(m.group(1)) + 1)
|
||||||
|
out_name = f"{filename_prefix}_{base_name}_{counter:03d}{ext}"
|
||||||
|
out_path = os.path.join(output_dir, out_name)
|
||||||
|
|
||||||
|
# --- Write the workflow metadata to a temp ffmetadata file ---
|
||||||
|
# (avoids Windows command-line length limits that -metadata args would hit)
|
||||||
|
meta_fd, meta_path = tempfile.mkstemp(suffix=".txt")
|
||||||
|
os.close(meta_fd)
|
||||||
|
with io.open(meta_path, "w", encoding="utf-8") as mf:
|
||||||
|
mf.write(";FFMETADATA1\n")
|
||||||
|
if prompt is not None:
|
||||||
|
mf.write(f"prompt={self._ffm_escape(json.dumps(prompt))}\n")
|
||||||
|
if extra_pnginfo and "workflow" in extra_pnginfo:
|
||||||
|
mf.write(f"workflow={self._ffm_escape(json.dumps(extra_pnginfo['workflow']))}\n")
|
||||||
|
|
||||||
|
# --- ffmpeg: stream-copy the video, only touch the audio ---
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", video_path,
|
||||||
|
"-i", second_input,
|
||||||
|
"-i", meta_path,
|
||||||
|
"-map", "0:v:0",
|
||||||
|
"-map", "1:a:0",
|
||||||
|
"-map_metadata", "2",
|
||||||
|
"-c:v", "copy",
|
||||||
|
]
|
||||||
|
if audio_codec == "copy":
|
||||||
|
cmd += ["-c:a", "copy"]
|
||||||
|
else:
|
||||||
|
cmd += ["-c:a", "aac", "-b:a", "192k"]
|
||||||
|
# allow arbitrary metadata keys in these containers
|
||||||
|
if ext.lower() in (".mp4", ".mov"):
|
||||||
|
cmd += ["-movflags", "use_metadata_tags"]
|
||||||
|
if shortest:
|
||||||
|
cmd.append("-shortest")
|
||||||
|
cmd.append(out_path)
|
||||||
|
|
||||||
|
def run_ffmpeg(command):
|
||||||
|
return subprocess.run(command, capture_output=True, text=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = run_ffmpeg(cmd)
|
||||||
|
if result.returncode != 0 and audio_codec == "copy":
|
||||||
|
# "copy" can fail when the source audio codec is incompatible with
|
||||||
|
# the output container (e.g. PCM in an AVI -> mp4). Retry with aac.
|
||||||
|
fallback_cmd = list(cmd)
|
||||||
|
for i, arg in enumerate(fallback_cmd):
|
||||||
|
if arg == "-c:a" and fallback_cmd[i + 1] == "copy":
|
||||||
|
fallback_cmd[i + 1] = "aac"
|
||||||
|
result = run_ffmpeg(fallback_cmd)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"ffmpeg failed (exit {result.returncode}):\n{result.stderr}")
|
||||||
|
finally:
|
||||||
|
for tmp in (tmp_audio_path, meta_path):
|
||||||
|
if tmp and os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
|
||||||
|
return (out_path,)
|
||||||
|
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {f"JsonPromptLoader -{__author__}": JsonPromptLoader,
|
NODE_CLASS_MAPPINGS = {f"JsonPromptLoader -{__author__}": JsonPromptLoader,
|
||||||
f"Resolution Scale -{__author__}": ResolutionScale,
|
f"Resolution Scale -{__author__}": ResolutionScale,
|
||||||
f"Regex Text Chopper -{__author__}": RegExTextChopper,
|
f"Regex Text Chopper -{__author__}": RegExTextChopper,
|
||||||
f"Auto Save Workflow -{__author__}": AutoSaveWorkflow,
|
f"Auto Save Workflow -{__author__}": AutoSaveWorkflow,
|
||||||
f"Load Image (from path) -{__author__}": LoadImageFromPathEnhanced,
|
f"Load Image (from path) -{__author__}": LoadImageFromPathEnhanced,
|
||||||
|
f"Image Composer -{__author__}": ImageComposer,
|
||||||
f"H3MotionContextClipStitcher -{__author__}": H3MotionContextClipStitcher,
|
f"H3MotionContextClipStitcher -{__author__}": H3MotionContextClipStitcher,
|
||||||
|
f"H3ClipRefiner -{__author__}": H3ClipRefiner,
|
||||||
f"H3MotionContextClipPurge -{__author__}": H3MotionContextClipPurge,
|
f"H3MotionContextClipPurge -{__author__}": H3MotionContextClipPurge,
|
||||||
f"H3ContextLatentConverter -{__author__}": H3ContextLatentConverter,
|
f"H3ContextLatentConverter -{__author__}": H3ContextLatentConverter,
|
||||||
|
f"H3AVLatentFromVideo -{__author__}": H3AVLatentFromVideo,
|
||||||
|
f"ReplaceAudioNoReEncode -{__author__}": ReplaceAudioNoReEncode,
|
||||||
"PromptTermList1": PromptTermList1,
|
"PromptTermList1": PromptTermList1,
|
||||||
"PromptTermList2": PromptTermList2,
|
"PromptTermList2": PromptTermList2,
|
||||||
"PromptTermList3": PromptTermList3,
|
"PromptTermList3": PromptTermList3,
|
||||||
@@ -497,9 +702,13 @@ NODE_DISPLAY_NAME_MAPPINGS = {f"JsonPromptLoader -{__author__}": f"Json Prompt L
|
|||||||
f"Regex Text Chopper -{__author__}": f"Regex Text Chopper /{__author__}",
|
f"Regex Text Chopper -{__author__}": f"Regex Text Chopper /{__author__}",
|
||||||
f"Auto Save Workflow -{__author__}": f"Auto Save Workflow /{__author__}",
|
f"Auto Save Workflow -{__author__}": f"Auto Save Workflow /{__author__}",
|
||||||
f"Load Image (from path) -{__author__}": f"Load Image (from path) /{__author__}",
|
f"Load Image (from path) -{__author__}": f"Load Image (from path) /{__author__}",
|
||||||
|
f"Image Composer -{__author__}": f"Image Composer /{__author__}",
|
||||||
f"H3MotionContextClipStitcher -{__author__}": f"H3 Motion Context Clip Stitcher /{__author__}",
|
f"H3MotionContextClipStitcher -{__author__}": f"H3 Motion Context Clip Stitcher /{__author__}",
|
||||||
|
f"H3ClipRefiner -{__author__}": f"H3 Clip Refiner /{__author__}",
|
||||||
f"H3MotionContextClipPurge -{__author__}": f"H3 Motion Context Clip Purge /{__author__}",
|
f"H3MotionContextClipPurge -{__author__}": f"H3 Motion Context Clip Purge /{__author__}",
|
||||||
f"H3ContextLatentConverter -{__author__}": f"H3 Context Latent Converter /{__author__}",
|
f"H3ContextLatentConverter -{__author__}": f"H3 Context Latent Converter /{__author__}",
|
||||||
|
f"H3AVLatentFromVideo -{__author__}": f"H3 AV Latent from Video /{__author__}",
|
||||||
|
f"ReplaceAudioNoReEncode -{__author__}": f"Replace Audio no ReEncode /{__author__}",
|
||||||
"PromptTermList1": f"PromptTermList 1 /{__author__}",
|
"PromptTermList1": f"PromptTermList 1 /{__author__}",
|
||||||
"PromptTermList2": f"PromptTermList 2 /{__author__}",
|
"PromptTermList2": f"PromptTermList 2 /{__author__}",
|
||||||
"PromptTermList3": f"PromptTermList 3 /{__author__}",
|
"PromptTermList3": f"PromptTermList 3 /{__author__}",
|
||||||
|
|||||||
+6
-5
@@ -1,5 +1,5 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-noembryo"
|
name = "comfyui-noembryo" # Unique identifier used in registry URLs (lowercase, no spaces)
|
||||||
description = """
|
description = """
|
||||||
A collection of nodes for ComyUI. \n
|
A collection of nodes for ComyUI. \n
|
||||||
* "Load Image (from path)" lets you load an image from any path in your computer or a URL, and use a selection rectangle at the preview to crop it. It can also
|
* "Load Image (from path)" lets you load an image from any path in your computer or a URL, and use a selection rectangle at the preview to crop it. It can also
|
||||||
@@ -12,14 +12,15 @@ limit the output's size in megapixels. \n
|
|||||||
* "PromptTermList (1-6)" nodes to help with the creation of Prompts. \n
|
* "PromptTermList (1-6)" nodes to help with the creation of Prompts. \n
|
||||||
* "Auto Save Workflow" can save the current workflow to a .json file automatically. \n
|
* "Auto Save Workflow" can save the current workflow to a .json file automatically. \n
|
||||||
"""
|
"""
|
||||||
version = "1.6.6"
|
version = "1.6.7"
|
||||||
license = {text = "MIT License"}
|
license = {text = "MIT License"}
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Repository = "https://github.com/noembryo/ComfyUI-noEmbryo"
|
Repository = "https://github.com/noembryo/ComfyUI-noEmbryo"
|
||||||
# Used by Comfy Registry https://comfyregistry.org
|
# Used by Comfy Registry https://comfyregistry.org
|
||||||
|
|
||||||
[tool.comfy]
|
[tool.comfy]
|
||||||
PublisherId = "noembryo"
|
PublisherId = "noembryo" # Obtained from your Comfy Registry account
|
||||||
DisplayName = "noEmbryoNodes"
|
DisplayName = "noEmbryoNodes" # The name displayed in the UI
|
||||||
Icon = "https://iili.io/JmSdmAJ.png"
|
Icon = "https://iili.io/JmSdmAJ.png" # (Optional) URL to an SVG/PNG icon
|
||||||
|
|||||||
-673
@@ -1,673 +0,0 @@
|
|||||||
"""H3 Motion Context clip stitcher for ComfyUI.
|
|
||||||
|
|
||||||
Loads NikoDemon80/ComfyUI-H3-Motion-Context clip archive files (h3_motion_context_av_v1),
|
|
||||||
decodes each approved clip once, crossfades the carried Motion Context head from
|
|
||||||
clips, and concatenates the picture/audio into one IMAGE + AUDIO pair.
|
|
||||||
|
|
||||||
This intentionally does NOT reconstruct a NestedTensor and feed the saved files back
|
|
||||||
into Motion Context.
|
|
||||||
The archive format is the sampler output, and this node is a final-media assembly tool.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import fnmatch
|
|
||||||
import glob
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
|
|
||||||
import torch
|
|
||||||
import torch.nn.functional as F
|
|
||||||
import folder_paths
|
|
||||||
from comfy.utils import ProgressBar
|
|
||||||
|
|
||||||
try:
|
|
||||||
from comfy_execution.graph_utils import get_original_node_id
|
|
||||||
except Exception:
|
|
||||||
get_original_node_id = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from safetensors.torch import load_file as st_load
|
|
||||||
except Exception:
|
|
||||||
st_load = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
import torchaudio
|
|
||||||
except Exception:
|
|
||||||
torchaudio = None
|
|
||||||
|
|
||||||
log_ = logging.getLogger("h3_motion_context_clip_stitcher")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_folder(path):
|
|
||||||
p = (path or "").strip().strip('"').strip("'")
|
|
||||||
if not p:
|
|
||||||
p = "h3_context"
|
|
||||||
candidates = [p, os.path.join(folder_paths.get_output_directory(), p)]
|
|
||||||
for c in candidates:
|
|
||||||
if os.path.isdir(c):
|
|
||||||
return os.path.abspath(c)
|
|
||||||
raise FileNotFoundError("H3 Motion Context Clip Stitcher: folder not found: %s\n"
|
|
||||||
"You can use an absolute path or a path relative to "
|
|
||||||
"ComfyUI's output folder." % p)
|
|
||||||
|
|
||||||
|
|
||||||
def _clip_number(path):
|
|
||||||
name = os.path.basename(path)
|
|
||||||
# noinspection RegExpUnnecessaryNonCapturingGroup
|
|
||||||
pat = re.compile(r"(?:^|_)(\d{5})(?:\.safetensors)$", re.IGNORECASE)
|
|
||||||
m = pat.search(name)
|
|
||||||
return int(m.group(1)) if m else -1
|
|
||||||
|
|
||||||
|
|
||||||
def _find_files(folder, pattern, first_clip, last_clip):
|
|
||||||
pattern = (pattern or "clip_*.safetensors").strip()
|
|
||||||
paths = []
|
|
||||||
for p in glob.glob(os.path.join(folder, pattern)):
|
|
||||||
if not os.path.isfile(p):
|
|
||||||
continue
|
|
||||||
if not p.lower().endswith(".safetensors"):
|
|
||||||
continue
|
|
||||||
idx = _clip_number(p)
|
|
||||||
if idx < 0:
|
|
||||||
continue
|
|
||||||
if idx < int(first_clip):
|
|
||||||
continue
|
|
||||||
if 0 < int(last_clip) < idx:
|
|
||||||
continue
|
|
||||||
paths.append((idx, p))
|
|
||||||
paths.sort(key=lambda x: x[0])
|
|
||||||
if not paths:
|
|
||||||
raise FileNotFoundError("H3 Motion Context Clip Stitcher: no numbered "
|
|
||||||
".safetensors files matched '%s' in %s."
|
|
||||||
% (pattern, folder))
|
|
||||||
|
|
||||||
# Do not silently skip a missing numbered clip. A gap usually means an
|
|
||||||
# approved clip was not saved, and silently stitching around it would make
|
|
||||||
# a misleading final timeline.
|
|
||||||
expected = paths[0][0]
|
|
||||||
for idx, _ in paths:
|
|
||||||
if idx != expected:
|
|
||||||
raise ValueError("H3 Motion Context Clip Stitcher: missing clip %05d between "
|
|
||||||
"the selected archive files." % expected)
|
|
||||||
expected += 1
|
|
||||||
return paths
|
|
||||||
|
|
||||||
|
|
||||||
def _load_archive(path):
|
|
||||||
if st_load is None:
|
|
||||||
raise RuntimeError("safetensors is unavailable in this "
|
|
||||||
"ComfyUI Python environment.")
|
|
||||||
# noinspection PyCallingNonCallable
|
|
||||||
data = st_load(path, device="cpu")
|
|
||||||
if "video" not in data or "audio" not in data:
|
|
||||||
raise ValueError("%s is not an h3_motion_context_av_v1 archive: "
|
|
||||||
"expected 'video' and 'audio'." % path)
|
|
||||||
video = data["video"]
|
|
||||||
audio = data["audio"]
|
|
||||||
if video.ndim != 5:
|
|
||||||
raise ValueError("%s: expected video [B,C,T,H,W], got %s"
|
|
||||||
% (path, tuple(video.shape)))
|
|
||||||
if audio.ndim != 4:
|
|
||||||
raise ValueError("%s: expected audio [B,C,2,T], got %s"
|
|
||||||
% (path, tuple(audio.shape)))
|
|
||||||
if video.shape[0] != 1 or audio.shape[0] != 1:
|
|
||||||
raise ValueError("%s: only batch size 1 archive clips are supported." % path)
|
|
||||||
return video, audio
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_video(vae, video_latent):
|
|
||||||
""" Decode the H3 video stream and normalize to ComfyUI IMAGE format.
|
|
||||||
"""
|
|
||||||
images = vae.decode(video_latent)
|
|
||||||
# H3's VAE normally returns [B,T,H,W,C]. Some VAE implementations can
|
|
||||||
# return [T,H,W,C], so accept both.
|
|
||||||
if images.ndim == 5:
|
|
||||||
images = images.reshape(-1, *images.shape[-3:])
|
|
||||||
elif images.ndim != 4:
|
|
||||||
raise RuntimeError("H3 video VAE returned unexpected shape %s"
|
|
||||||
% (tuple(images.shape),))
|
|
||||||
return images.to(torch.float32).clamp(0, 1).cpu()
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_audio(audio_vae, audio_latent):
|
|
||||||
""" Decode the H3 audio stream using the same convention as ComfyUI's VAEDecodeAudio.
|
|
||||||
"""
|
|
||||||
audio = audio_vae.decode(audio_latent)
|
|
||||||
# Current ComfyUI audio VAE returns [B,L,C]. Convert to [B,C,L].
|
|
||||||
if audio.ndim != 3:
|
|
||||||
raise RuntimeError("H3 audio VAE returned unexpected shape %s" % (tuple(images.shape),))
|
|
||||||
audio = audio.movedim(-1, 1)
|
|
||||||
sr = int(getattr(audio_vae, "audio_sample_rate_output",
|
|
||||||
getattr(audio_vae, "audio_sample_rate", 32000)))
|
|
||||||
return {"waveform": audio.to(torch.float32).cpu(), "sample_rate": sr}
|
|
||||||
|
|
||||||
|
|
||||||
def _resample_audio(audio, target_sr):
|
|
||||||
if audio is None:
|
|
||||||
return None
|
|
||||||
sr = int(audio["sample_rate"])
|
|
||||||
if sr == int(target_sr):
|
|
||||||
return audio
|
|
||||||
if torchaudio is None:
|
|
||||||
raise RuntimeError("Audio sample rates differ (%d vs %d), but torchaudio is "
|
|
||||||
"unavailable to resample them." % (sr, int(target_sr)))
|
|
||||||
# noinspection PyUnresolvedReferences
|
|
||||||
waveform = torchaudio.functional.resample(audio["waveform"], sr, int(target_sr))
|
|
||||||
return {"waveform": waveform, "sample_rate": int(target_sr)}
|
|
||||||
|
|
||||||
|
|
||||||
def _crossfade_boundary(prev_tail_images, cur_images, prev_tail_wave, cur_wave,
|
|
||||||
overlap_frames, cross_samples):
|
|
||||||
""" Crossfade the previous clip's tail with the current clip's head.
|
|
||||||
|
|
||||||
prev_tail_images: [L,H,W,C] cur_images: [T,H,W,C]
|
|
||||||
prev_tail_wave : [1,C,Ls] cur_wave: [1,C,Cs] (or None)
|
|
||||||
Returns (blend_images [L,H,W,C], blend_wave [1,C,Ls] or None).
|
|
||||||
|
|
||||||
Video uses a linear dissolve ramp; audio uses an equal-power (cos/sin)
|
|
||||||
ramp over the same time window so picture and sound stay in sync.
|
|
||||||
"""
|
|
||||||
L = int(overlap_frames)
|
|
||||||
if L <= 0:
|
|
||||||
return cur_images[:0], None
|
|
||||||
if L == 1:
|
|
||||||
alpha = torch.full((1, 1, 1, 1), 0.5, dtype=prev_tail_images.dtype,
|
|
||||||
device=prev_tail_images.device)
|
|
||||||
else:
|
|
||||||
alpha = torch.linspace(0.0, 1.0, L, dtype=prev_tail_images.dtype,
|
|
||||||
device=prev_tail_images.device).view(L, 1, 1, 1)
|
|
||||||
blend_images = prev_tail_images * (1.0 - alpha) + cur_images[:L] * alpha
|
|
||||||
|
|
||||||
blend_wave = None
|
|
||||||
if prev_tail_wave is not None and cur_wave is not None:
|
|
||||||
n = int(cross_samples)
|
|
||||||
if n <= 0:
|
|
||||||
blend_wave = prev_tail_wave
|
|
||||||
else:
|
|
||||||
n = min(n, int(prev_tail_wave.shape[-1]), int(cur_wave.shape[-1]))
|
|
||||||
theta = torch.linspace(0.0, 1.5707963267948966, n, dtype=prev_tail_wave.dtype,
|
|
||||||
device=prev_tail_wave.device).view(1, 1, n)
|
|
||||||
blend_wave = (prev_tail_wave[..., :n] * torch.cos(theta)
|
|
||||||
+ cur_wave[..., :n] * torch.sin(theta))
|
|
||||||
return blend_images, blend_wave
|
|
||||||
|
|
||||||
|
|
||||||
def _av_from_live_latent(latent):
|
|
||||||
""" Extract (video, audio) tensors from an in-memory H3 AV LATENT,
|
|
||||||
using the same unpacking convention as NikoDemon80's own
|
|
||||||
_streams_from_latent()/save(): latent["samples"] is a NestedTensor
|
|
||||||
(or tuple/list) whose unbind() gives (video, audio) in that order.
|
|
||||||
"""
|
|
||||||
if not isinstance(latent, dict) or "samples" not in latent:
|
|
||||||
raise ValueError("h3_motion_context: expected a MiniMax H3 AV latent dict with "
|
|
||||||
"a 'samples' key, got %r" % type(latent))
|
|
||||||
samples = latent["samples"]
|
|
||||||
if hasattr(samples, "unbind"):
|
|
||||||
parts = list(samples.unbind())
|
|
||||||
elif isinstance(samples, (tuple, list)):
|
|
||||||
parts = list(samples)
|
|
||||||
else:
|
|
||||||
raise ValueError("h3_motion_context: expected a MiniMax H3 AV latent (a nested "
|
|
||||||
"video/audio pair), got %r" % type(samples))
|
|
||||||
if len(parts) < 2:
|
|
||||||
raise ValueError("h3_motion_context: latent has no audio stream; wire the "
|
|
||||||
"sampler output of an H3 AV graph.")
|
|
||||||
# NestedTensor.unbind() returns views into the packed underlying storage.
|
|
||||||
# Passing such views (or tensors still carrying nested metadata) to a VAE's
|
|
||||||
# CUDA kernels can trigger cudaErrorIllegalAddress. Force a real, dense,
|
|
||||||
# detached CPU copy of each stream before handing them to the VAE.
|
|
||||||
video = parts[0].detach().to("cpu", copy=True).contiguous()
|
|
||||||
audio = parts[1].detach().to("cpu", copy=True).contiguous()
|
|
||||||
# Live streams can carry the same shapes as the archive files (video
|
|
||||||
# [B,C,T,H,W] or [C,T,H,W]; audio [B,C,2,T] or [B,L,C]).
|
|
||||||
expected_ndim = {"video": (4, 5), "audio": (3, 4)}
|
|
||||||
for name, t in (("video", video), ("audio", audio)):
|
|
||||||
if t.ndim not in expected_ndim[name]:
|
|
||||||
raise ValueError("h3_motion_context: live %s stream has unexpected "
|
|
||||||
"shape %s." % (name, tuple(t.shape)))
|
|
||||||
if not torch.is_floating_point(t):
|
|
||||||
raise ValueError("h3_motion_context: live %s stream is not a float "
|
|
||||||
"tensor (dtype %s)." % (name, t.dtype))
|
|
||||||
return video, audio
|
|
||||||
|
|
||||||
|
|
||||||
class H3MotionContextClipStitcher:
|
|
||||||
""" Load, decode, and crossfade approved H3 Motion Context clips.
|
|
||||||
"""
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(cls):
|
|
||||||
return {"required": {"folder": ("STRING", {"default": "h3_context",
|
|
||||||
"tooltip": "Folder containing clip_00001.safetensors, "
|
|
||||||
"clip_00002.safetensors, etc.\nAbsolute paths and paths relative "
|
|
||||||
"to ComfyUI/output are accepted."}),
|
|
||||||
"pattern": ("STRING", {"default": "clip_*.safetensors",
|
|
||||||
"tooltip": "Filename glob. The final five-digit number is treated as "
|
|
||||||
"the clip index."}),
|
|
||||||
"first_clip": ("INT", {"default": 1, "min": 1, "max": 9999,
|
|
||||||
"tooltip": "First approved clip to include."}),
|
|
||||||
"last_clip": ("INT", {"default": 0, "min": 0, "max": 9999,
|
|
||||||
"tooltip": "Last clip to include. 0 = every clip from first_clip onward."}),
|
|
||||||
"context_length": (["5", "22", "39", "56"], {"default": "22",
|
|
||||||
"tooltip": "Number of decoded frames to crossfade at each clip boundary. "
|
|
||||||
"The normal setting is 22 frames.\n"
|
|
||||||
"This is the overlap length that is dissolved between "
|
|
||||||
"adjacent clips.\n"
|
|
||||||
"5, 22, 39 or 56 are the lengths that are a whole number of "
|
|
||||||
"latent steps, which is why other numbers aren't offered."}),
|
|
||||||
"fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 240.0, "step": 0.001,
|
|
||||||
"tooltip": "H3 native output rate. Keep this at 24 unless your workflow "
|
|
||||||
"deliberately changes it."}), },
|
|
||||||
"optional": {"video_vae": ("VAE", {"tooltip": "MiniMax H3 video VAE "
|
|
||||||
"(FP16 or INT8 ConvRot)."}),
|
|
||||||
"audio_vae": ("VAE", {
|
|
||||||
"tooltip": "MiniMax H3 audio VAE FP32. Required for the AUDIO "
|
|
||||||
"output."}),
|
|
||||||
"latent": ("LATENT", {
|
|
||||||
"tooltip": "Optional: the currently-generated AV latent (from your "
|
|
||||||
"H3 sampler), used in place of the highest-numbered file "
|
|
||||||
"on disk."}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
RETURN_TYPES = ("IMAGE", "AUDIO", "INT", "STRING")
|
|
||||||
RETURN_NAMES = ("images", "audio", "frame_count", "report")
|
|
||||||
FUNCTION = "stitch"
|
|
||||||
CATEGORY = "noEmbryo"
|
|
||||||
DESCRIPTION = ("Final assembly for NikoDemon80's H3 Motion Context AV clip archives.\n"
|
|
||||||
"Loads numbered h3_motion_context_av_v1 files, decodes one clip at a "
|
|
||||||
"time, dissolves the overlap between adjacent clips (video + synchronized audio), "
|
|
||||||
"and concatenates them to a final video and audio stream.")
|
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
|
||||||
@classmethod
|
|
||||||
def IS_CHANGED(cls, folder, pattern, first_clip, last_clip, context_length, fps,
|
|
||||||
video_vae=None, audio_vae=None, latent=None):
|
|
||||||
# noinspection PyBroadException
|
|
||||||
try:
|
|
||||||
d = _resolve_folder(folder)
|
|
||||||
files = _find_files(d, pattern, first_clip, last_clip)
|
|
||||||
# noinspection PyTypeChecker
|
|
||||||
return tuple((p, os.stat(p).st_mtime_ns, os.path.getsize(p))
|
|
||||||
for _, p in files) + (int(context_length), float(fps),)
|
|
||||||
except Exception:
|
|
||||||
return float("NaN")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def stitch(folder, pattern, first_clip, last_clip, context_length, fps,
|
|
||||||
video_vae=None, audio_vae=None, latent=None,):
|
|
||||||
if video_vae is None:
|
|
||||||
raise ValueError("Connect your MiniMax H3 video VAE to 'video_vae'.")
|
|
||||||
if st_load is None:
|
|
||||||
raise RuntimeError("safetensors is not available in this ComfyUI environment")
|
|
||||||
|
|
||||||
d = _resolve_folder(folder)
|
|
||||||
files = _find_files(d, pattern, first_clip, last_clip)
|
|
||||||
live_entry = None
|
|
||||||
|
|
||||||
if latent is not None:
|
|
||||||
if files:
|
|
||||||
files = files[:-1] # drop the presumed-duplicate on-disk file
|
|
||||||
live_index = files[-1][0] + 1 if files else int(first_clip)
|
|
||||||
else:
|
|
||||||
live_index = int(first_clip)
|
|
||||||
video_latent, audio_latent = _av_from_live_latent(latent)
|
|
||||||
live_entry = (live_index, None, video_latent, audio_latent) # path=None marks it as live
|
|
||||||
|
|
||||||
image_parts = []
|
|
||||||
audio_parts = []
|
|
||||||
report_lines = []
|
|
||||||
target_sr = None
|
|
||||||
|
|
||||||
overlap = int(context_length)
|
|
||||||
|
|
||||||
prev_tail_img = None
|
|
||||||
prev_tail_wave = None
|
|
||||||
|
|
||||||
all_entries = [(idx, path, None, None) for idx, path in files]
|
|
||||||
if live_entry is not None:
|
|
||||||
# noinspection PyTypeChecker
|
|
||||||
all_entries.append(live_entry)
|
|
||||||
|
|
||||||
total_count = len(files) + (1 if live_entry is not None else 0)
|
|
||||||
# noinspection PyCallingNonCallable
|
|
||||||
pbar = ProgressBar(total_count, node_id=get_original_node_id()
|
|
||||||
if get_original_node_id is not None else None)
|
|
||||||
log_.info("H3 clip stitcher: %d clip(s) selected from %s", total_count, d)
|
|
||||||
|
|
||||||
# Degenerate crossfade (no overlap or a single clip) falls back to a
|
|
||||||
# plain concatenation, which is exactly what the trim modes would do.
|
|
||||||
crossfade_active = overlap > 0 and len(all_entries) > 1
|
|
||||||
|
|
||||||
for pos, (idx, path, live_video, live_audio) in enumerate(all_entries):
|
|
||||||
if path is not None:
|
|
||||||
video_latent, audio_latent = _load_archive(path)
|
|
||||||
else:
|
|
||||||
video_latent, audio_latent = live_video, live_audio
|
|
||||||
log_.info("H3 clip stitcher: clip %05d taken from live latent input",
|
|
||||||
idx)
|
|
||||||
|
|
||||||
# Decode one clip at a time. The decoded result is immediately moved
|
|
||||||
# to CPU, so a long chain does not keep every VAE result on VRAM.
|
|
||||||
images = _decode_video(video_vae, video_latent)
|
|
||||||
del video_latent
|
|
||||||
|
|
||||||
audio = None
|
|
||||||
if audio_vae is not None:
|
|
||||||
audio = _decode_audio(audio_vae, audio_latent,
|
|
||||||
# normalize=normalize_audio_per_clip
|
|
||||||
)
|
|
||||||
del audio_latent
|
|
||||||
|
|
||||||
decoded_frames = int(images.shape[0])
|
|
||||||
is_last = pos == len(all_entries) - 1
|
|
||||||
|
|
||||||
if not crossfade_active:
|
|
||||||
# Single clip (or zero overlap): no boundaries to blend, just
|
|
||||||
# emit the whole decoded clip and finish.
|
|
||||||
if audio is not None and target_sr is None:
|
|
||||||
target_sr = int(audio["sample_rate"])
|
|
||||||
image_parts.append(images)
|
|
||||||
if audio is not None:
|
|
||||||
audio_parts.append(audio["waveform"])
|
|
||||||
report_lines.append("clip_%05d: decoded=%d frames, no crossfade "
|
|
||||||
"(single clip), audio=%.4fs"
|
|
||||||
% (idx, decoded_frames, 0.0
|
|
||||||
if audio is None else audio["waveform"].shape[-1]
|
|
||||||
/ float(audio["sample_rate"])))
|
|
||||||
pbar.update_absolute(pos + 1, total_count)
|
|
||||||
del images
|
|
||||||
if audio is not None:
|
|
||||||
del audio
|
|
||||||
continue
|
|
||||||
|
|
||||||
if crossfade_active:
|
|
||||||
if decoded_frames < 2 * overlap:
|
|
||||||
raise ValueError("Crossfade requires each clip to have at least "
|
|
||||||
"2*context_length (%d) frames; clip %05d has %d."
|
|
||||||
% (overlap, idx, decoded_frames))
|
|
||||||
|
|
||||||
# Resample this clip's audio to the shared target rate before
|
|
||||||
# splitting, so the head/tail sample counts line up across clips.
|
|
||||||
if audio is not None:
|
|
||||||
if target_sr is None:
|
|
||||||
target_sr = int(audio["sample_rate"])
|
|
||||||
audio = _resample_audio(audio, target_sr)
|
|
||||||
if prev_tail_wave is not None:
|
|
||||||
prev_tail_wave = _resample_audio(prev_tail_wave, target_sr)
|
|
||||||
|
|
||||||
n = 0
|
|
||||||
if audio is not None:
|
|
||||||
sr = int(audio["sample_rate"])
|
|
||||||
n = int(round((overlap / float(fps)) * sr))
|
|
||||||
if n <= 0:
|
|
||||||
n = 1
|
|
||||||
if n >= audio["waveform"].shape[-1]:
|
|
||||||
raise ValueError("Audio is too short to extract a %d-frame "
|
|
||||||
"(%0.4fs) crossfade head/tail for clip %05d."
|
|
||||||
% (overlap, overlap / float(fps), idx))
|
|
||||||
|
|
||||||
head_img = images[:overlap]
|
|
||||||
body_img = images[overlap:-overlap]
|
|
||||||
tail_img = images[-overlap:]
|
|
||||||
|
|
||||||
head_wave = body_wave = tail_wave = None
|
|
||||||
if audio is not None:
|
|
||||||
wave = audio["waveform"]
|
|
||||||
sr = int(audio["sample_rate"])
|
|
||||||
head_wave = {"waveform": wave[..., :n], "sample_rate": sr}
|
|
||||||
body_wave = {"waveform": wave[..., n:-n], "sample_rate": sr}
|
|
||||||
tail_wave = {"waveform": wave[..., -n:], "sample_rate": sr}
|
|
||||||
|
|
||||||
if pos == 0:
|
|
||||||
# First clip: emit head+body raw, buffer the tail for the next boundary.
|
|
||||||
image_parts.append(torch.cat([head_img, body_img], dim=0))
|
|
||||||
if audio is not None:
|
|
||||||
audio_parts.append(torch.cat([head_wave["waveform"],
|
|
||||||
body_wave["waveform"]], dim=-1))
|
|
||||||
prev_tail_img = tail_img
|
|
||||||
prev_tail_wave = tail_wave
|
|
||||||
else:
|
|
||||||
blend_img, blend_wave = _crossfade_boundary(prev_tail_img, images,
|
|
||||||
prev_tail_wave[
|
|
||||||
"waveform"] if prev_tail_wave is not None else None,
|
|
||||||
audio["waveform"] if audio is not None else None, overlap, n)
|
|
||||||
image_parts.append(blend_img)
|
|
||||||
if audio is not None:
|
|
||||||
audio_parts.append(blend_wave)
|
|
||||||
audio_parts.append(body_wave["waveform"])
|
|
||||||
if is_last:
|
|
||||||
# Last clip: emit body+tail raw after its boundary blend.
|
|
||||||
image_parts.append(torch.cat([body_img, tail_img], dim=0))
|
|
||||||
if audio is not None:
|
|
||||||
audio_parts.append(tail_wave["waveform"])
|
|
||||||
else:
|
|
||||||
image_parts.append(body_img)
|
|
||||||
prev_tail_img = tail_img
|
|
||||||
prev_tail_wave = tail_wave
|
|
||||||
|
|
||||||
kept_frames = decoded_frames - (overlap if not is_last else 0)
|
|
||||||
audio_sec = (0.0 if audio is None else
|
|
||||||
audio["waveform"].shape[-1] / float(audio["sample_rate"]))
|
|
||||||
report_lines.append("clip_%05d: decoded=%d frames, crossfade=%d frames "
|
|
||||||
"(%.4fs), kept=%d, audio=%.4fs"
|
|
||||||
% (idx, decoded_frames, overlap, overlap / float(fps),
|
|
||||||
kept_frames, audio_sec))
|
|
||||||
|
|
||||||
# Advance the green progress bar once this clip is fully decoded and
|
|
||||||
# its parts have been appended to the stitched timeline.
|
|
||||||
pbar.update_absolute(pos + 1, total_count)
|
|
||||||
|
|
||||||
del images
|
|
||||||
if audio is not None:
|
|
||||||
del audio
|
|
||||||
|
|
||||||
final_images = torch.cat(image_parts, dim=0).contiguous()
|
|
||||||
del image_parts
|
|
||||||
|
|
||||||
final_audio = None
|
|
||||||
if audio_parts:
|
|
||||||
final_waveform = torch.cat(audio_parts, dim=-1).contiguous()
|
|
||||||
del audio_parts
|
|
||||||
final_audio = {"waveform": final_waveform, "sample_rate": int(target_sr)}
|
|
||||||
|
|
||||||
frame_count = int(final_images.shape[0])
|
|
||||||
video_seconds = frame_count / float(fps)
|
|
||||||
audio_seconds = (final_audio["waveform"].shape[-1]
|
|
||||||
/ float(final_audio["sample_rate"])
|
|
||||||
if final_audio is not None else 0.0)
|
|
||||||
|
|
||||||
report_lines.append("TOTAL: %d frames = %.4fs at %.3f fps; audio=%.4fs%s"
|
|
||||||
% (frame_count, video_seconds, float(fps), audio_seconds,
|
|
||||||
"" if final_audio is not None
|
|
||||||
else " (no audio_vae connected)"))
|
|
||||||
report = "\n".join(report_lines)
|
|
||||||
log_.info("H3 clip stitcher finished: %d frames (%.3fs), audio %.3fs",
|
|
||||||
frame_count, video_seconds, audio_seconds)
|
|
||||||
|
|
||||||
return final_images, final_audio, frame_count, report
|
|
||||||
|
|
||||||
|
|
||||||
class _AVStreamPair:
|
|
||||||
"""Minimal stand-in for a NestedTensor: wraps (video, audio) tensors and
|
|
||||||
exposes the unbind() interface that comfy-core's LTXVSeparateAVLatent
|
|
||||||
(and the H3 sampler code) expects. The wrapped tensors are always dense,
|
|
||||||
detached, contiguous copies, so they are safe to feed to the VAE kernels.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, video, audio):
|
|
||||||
self._parts = [video, audio]
|
|
||||||
|
|
||||||
def unbind(self):
|
|
||||||
# noinspection PyTypeChecker
|
|
||||||
return tuple(self._parts)
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return iter(self._parts)
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self._parts)
|
|
||||||
|
|
||||||
|
|
||||||
class H3ContextLatentConverter:
|
|
||||||
""" Convert an H3 Motion Context archive latent (as loaded by
|
|
||||||
MiniMaxH3MotionContextLoadLatent, whose 'samples' is a plain list) into
|
|
||||||
the AV latent form that comfy-core's LTXVSeparateAVLatent expects
|
|
||||||
(av_latent["samples"].unbind() -> (video, audio)).
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(cls):
|
|
||||||
return {"required": {"latent": ("LATENT", {
|
|
||||||
"tooltip": "An H3 AV latent, e.g. the output of "
|
|
||||||
"MiniMaxH3MotionContextLoadLatent. Its 'samples' must be a "
|
|
||||||
"NestedTensor or a (video, audio) pair."})}}
|
|
||||||
|
|
||||||
RETURN_TYPES = ("LATENT",)
|
|
||||||
RETURN_NAMES = ("latent",)
|
|
||||||
FUNCTION = "convert"
|
|
||||||
CATEGORY = "noEmbryo"
|
|
||||||
DESCRIPTION = ("Repackages the AV latent loaded from an H3 Motion Context clip "
|
|
||||||
"archive into the nested (video, audio) form that "
|
|
||||||
"LTXVSeparateAVLatent expects, so saved clips can be re-sampled, "
|
|
||||||
"upscaled, or re-saved.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def convert(latent):
|
|
||||||
if not isinstance(latent, dict) or "samples" not in latent:
|
|
||||||
raise ValueError("h3_context_latent_converter: expected a latent dict with "
|
|
||||||
"a 'samples' key, got %r" % type(latent))
|
|
||||||
|
|
||||||
out = dict(latent)
|
|
||||||
samples = latent["samples"]
|
|
||||||
|
|
||||||
if hasattr(samples, "unbind"):
|
|
||||||
parts = list(samples.unbind())
|
|
||||||
elif isinstance(samples, (tuple, list)):
|
|
||||||
parts = list(samples)
|
|
||||||
else:
|
|
||||||
raise ValueError("h3_context_latent_converter: 'samples' is neither "
|
|
||||||
"unbindable nor a (video, audio) pair, got %r"
|
|
||||||
% type(samples))
|
|
||||||
|
|
||||||
if len(parts) < 2:
|
|
||||||
raise ValueError("h3_context_latent_converter: latent has no audio "
|
|
||||||
"stream (only %d part(s)); expected an H3 AV latent."
|
|
||||||
% len(parts))
|
|
||||||
|
|
||||||
expected_ndim = {"video": (4, 5), "audio": (3, 4)}
|
|
||||||
names = ("video", "audio")
|
|
||||||
dense = []
|
|
||||||
for name, t in zip(names, parts[:2]):
|
|
||||||
if t.ndim not in expected_ndim[name]:
|
|
||||||
raise ValueError("h3_context_latent_converter: %s stream has "
|
|
||||||
"unexpected shape %s." % (name, tuple(t.shape)))
|
|
||||||
if not torch.is_floating_point(t):
|
|
||||||
raise ValueError("h3_context_latent_converter: %s stream is not a "
|
|
||||||
"float tensor (dtype %s)." % (name, t.dtype))
|
|
||||||
# Force a real, dense, detached CPU copy: views into packed storage
|
|
||||||
# (or tensors still carrying nested metadata) can make VAE CUDA
|
|
||||||
# kernels crash with cudaErrorIllegalAddress.
|
|
||||||
dense.append(t.detach().to("cpu", copy=True).contiguous())
|
|
||||||
|
|
||||||
converted = {k: v for k, v in out.items() if k != "samples"}
|
|
||||||
converted["samples"] = _AVStreamPair(dense[0], dense[1])
|
|
||||||
return (converted,)
|
|
||||||
|
|
||||||
|
|
||||||
class H3MotionContextClipPurge:
|
|
||||||
""" Delete the saved H3 Motion Context clip archive files from a folder.
|
|
||||||
"""
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(cls):
|
|
||||||
return {"required": {"mode": ("BOOLEAN", {"default": True,
|
|
||||||
"label_on": "Purge", "label_off": "Preview (dry run)",
|
|
||||||
"tooltip": "Purge (Enabled): delete the matching files.\n"
|
|
||||||
"Preview (dry run, Disabled): delete nothing; the report "
|
|
||||||
"just lists the files that would be deleted."}),
|
|
||||||
"folder": ("STRING", {"default": "h3_context",
|
|
||||||
"tooltip": "Folder whose root-level clip archives will be deleted.\n"
|
|
||||||
"Absolute paths and paths relative to ComfyUI/output are "
|
|
||||||
"accepted."}),
|
|
||||||
"pattern": ("STRING", {"default": "clip_*.safetensors",
|
|
||||||
"tooltip": "Filename glob. Only root-level FILES matching this "
|
|
||||||
"pattern are deleted.\nSub-folders are never touched."}), },
|
|
||||||
"hidden": {"mode": "BOOLEAN"}}
|
|
||||||
|
|
||||||
RETURN_TYPES = ("STRING",)
|
|
||||||
RETURN_NAMES = ("report",)
|
|
||||||
FUNCTION = "purge"
|
|
||||||
CATEGORY = "noEmbryo"
|
|
||||||
OUTPUT_NODE = True
|
|
||||||
DESCRIPTION = ("Deletes the numbered h3_motion_context_av_v1 clip archive files "
|
|
||||||
"at the root of a folder (default: h3_context).\n"
|
|
||||||
"Purge (Enabled): deletes the files.\n"
|
|
||||||
"Preview (Disabled): dry run - the report only lists what would "
|
|
||||||
"be deleted.\nOnly files matching the pattern are removed; "
|
|
||||||
"sub-folders and everything inside them are left untouched.")
|
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
|
||||||
@classmethod
|
|
||||||
def IS_CHANGED(cls, mode, folder, pattern):
|
|
||||||
return float("NaN")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def purge(mode, folder, pattern):
|
|
||||||
d = _resolve_folder(folder)
|
|
||||||
pattern = (pattern or "clip_*.safetensors").strip()
|
|
||||||
|
|
||||||
doomed = []
|
|
||||||
for entry in os.scandir(d):
|
|
||||||
if entry.is_file(follow_symlinks=False) and not entry.is_dir():
|
|
||||||
if fnmatch.fnmatch(entry.name, pattern):
|
|
||||||
doomed.append((entry.name, entry.stat().st_size))
|
|
||||||
|
|
||||||
if not mode: # Preview (dry run)
|
|
||||||
lines = ["H3 clip purge (DRY RUN) in %s - nothing was deleted:" % d]
|
|
||||||
lines += [" would delete: %s (%s)" % (name, _fmt_size(size))
|
|
||||||
for name, size in doomed] or [" no matching files."]
|
|
||||||
lines.append("TOTAL: %d file(s), %s" %
|
|
||||||
(len(doomed), _fmt_size(sum(s for _, s in doomed))))
|
|
||||||
report = "\n".join(lines)
|
|
||||||
log_.info(report)
|
|
||||||
return (report,)
|
|
||||||
|
|
||||||
deleted = 0
|
|
||||||
freed = 0
|
|
||||||
lines = ["H3 clip purge in %s:" % d]
|
|
||||||
for name, size in doomed:
|
|
||||||
try:
|
|
||||||
os.remove(os.path.join(d, name))
|
|
||||||
deleted += 1
|
|
||||||
freed += size
|
|
||||||
lines.append(" deleted: %s (%s)" % (name, _fmt_size(size)))
|
|
||||||
except OSError as e:
|
|
||||||
lines.append(" FAILED to delete %s: %s" % (name, e))
|
|
||||||
if not deleted and not doomed:
|
|
||||||
lines.append(" no matching files.")
|
|
||||||
lines.append("TOTAL: deleted %d file(s), freed %s" %
|
|
||||||
(deleted, _fmt_size(freed)))
|
|
||||||
report = "\n".join(lines)
|
|
||||||
log_.info(report)
|
|
||||||
return (report,)
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_size(num_bytes):
|
|
||||||
size = float(num_bytes)
|
|
||||||
for unit in ("B", "KiB", "MiB", "GiB"):
|
|
||||||
if size < 1024.0:
|
|
||||||
return "%.1f %s" % (size, unit)
|
|
||||||
size /= 1024.0
|
|
||||||
return "%.1f TiB" % size
|
|
||||||
|
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
|
||||||
"H3MotionContextClipStitcher": H3MotionContextClipStitcher,
|
|
||||||
"H3ContextLatentConverter": H3ContextLatentConverter,
|
|
||||||
"H3MotionContextClipPurge": H3MotionContextClipPurge,
|
|
||||||
}
|
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
||||||
"H3MotionContextClipStitcher": "H3 Motion Context Clip Stitcher",
|
|
||||||
"H3ContextLatentConverter": "H3 Context Latent Converter",
|
|
||||||
"H3MotionContextClipPurge": "H3 Motion Context Clip Purge",
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,35 @@ import {api} from "../../scripts/api.js";
|
|||||||
// Store last browsed path
|
// Store last browsed path
|
||||||
let lastBrowsedPath = '';
|
let lastBrowsedPath = '';
|
||||||
|
|
||||||
|
// Registry of ImageComposer nodes for global graph change notifications
|
||||||
|
const composerNodes = new Set();
|
||||||
|
|
||||||
|
// Global graph change listener — fires when ANY connection changes in the graph.
|
||||||
|
// This catches upstream bypasses (e.g. disconnecting a loader from a KJ Set node)
|
||||||
|
// that don't trigger the Composer's own onConnectionsChange.
|
||||||
|
let graphChangeHandlerInstalled = false;
|
||||||
|
function installGraphChangeListener() {
|
||||||
|
if (graphChangeHandlerInstalled) return;
|
||||||
|
graphChangeHandlerInstalled = true;
|
||||||
|
// ComfyUI's LGraphCanvas fires "graphchange" on the canvas when connections change.
|
||||||
|
// We hook into the canvas to catch all connection changes.
|
||||||
|
const canvas = app.canvas;
|
||||||
|
if (canvas) {
|
||||||
|
const prevOnGraphChange = canvas.onGraphChange;
|
||||||
|
canvas.onGraphChange = function () {
|
||||||
|
const r = prevOnGraphChange?.apply(this, arguments);
|
||||||
|
// Notify all Composer nodes to refresh thumbnails
|
||||||
|
for (const node of composerNodes) {
|
||||||
|
if (node.refreshThumbs) {
|
||||||
|
node.refreshThumbs();
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Return the directory of a path string (handles Windows/Unix + [input]/[output]/[temp] suffixes). */
|
/** Return the directory of a path string (handles Windows/Unix + [input]/[output]/[temp] suffixes). */
|
||||||
function dirnameOf(pathStr) {
|
function dirnameOf(pathStr) {
|
||||||
if (!pathStr || typeof pathStr !== 'string') return '';
|
if (!pathStr || typeof pathStr !== 'string') return '';
|
||||||
@@ -554,6 +583,762 @@ const RESIZE_ZONE = 15;
|
|||||||
const PREVIEW_TOOLTIP =
|
const PREVIEW_TOOLTIP =
|
||||||
"Drag to crop · Drag inside to move · Drag corners to resize · Click outside selection to clear · Click ↻ to rotate 90°";
|
"Drag to crop · Drag inside to move · Drag corners to resize · Click outside selection to clear · Click ↻ to rotate 90°";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Image Composer — growing IMAGE inputs and a live arrangement preview.
|
||||||
|
// Mirrors the backend's natural-mode skyline packing (image_nodes.py).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const IC_MAX_IMAGES = 16;
|
||||||
|
const IC_ALIGN = 16;
|
||||||
|
const IC_PACK_ASPECT_MIN = 0.45;
|
||||||
|
const IC_PACK_ASPECT_MAX = 2.2;
|
||||||
|
const IC_PACK_WIDTH_STEPS = 48;
|
||||||
|
const IC_EPS = 1e-9;
|
||||||
|
const IC_BACKGROUNDS = { black: "#000", grey: "#808080", white: "#fff" };
|
||||||
|
|
||||||
|
// Direct port of the backend skyline packer.
|
||||||
|
function icSkylinePack(sizes, width, gap) {
|
||||||
|
const sky = [[0, width, 0]];
|
||||||
|
const placed = [];
|
||||||
|
for (const [w, h] of sizes) {
|
||||||
|
const iw = w + gap, ih = h + gap;
|
||||||
|
if (iw > width + IC_EPS) return null;
|
||||||
|
let best = null;
|
||||||
|
for (let i = 0; i < sky.length; i++) {
|
||||||
|
const start = sky[i][0];
|
||||||
|
if (start + iw > width + IC_EPS) continue;
|
||||||
|
let y = 0, span = iw, j = i;
|
||||||
|
while (span > IC_EPS && j < sky.length) {
|
||||||
|
if (sky[j][2] > y) y = sky[j][2];
|
||||||
|
span -= sky[j][1];
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (span > IC_EPS) continue;
|
||||||
|
if (best === null || y < best[0] || (y === best[0] && start < best[1]))
|
||||||
|
best = [y, start];
|
||||||
|
}
|
||||||
|
if (best === null) return null;
|
||||||
|
const [y, x] = best;
|
||||||
|
placed.push([x, y, w, h]);
|
||||||
|
const cut = [], end = x + iw;
|
||||||
|
for (const [sx, sw, sy] of sky) {
|
||||||
|
if (sx + sw <= x + IC_EPS || sx >= end - IC_EPS) { cut.push([sx, sw, sy]); continue; }
|
||||||
|
if (sx < x) cut.push([sx, x - sx, sy]);
|
||||||
|
if (sx + sw > end) cut.push([end, sx + sw - end, sy]);
|
||||||
|
}
|
||||||
|
cut.push([x, iw, y + ih]);
|
||||||
|
cut.sort((a, b) => a[0] - b[0]);
|
||||||
|
const merged = [];
|
||||||
|
for (const seg of cut) {
|
||||||
|
if (merged.length && Math.abs(merged[merged.length - 1][2] - seg[2]) < IC_EPS)
|
||||||
|
merged[merged.length - 1][1] += seg[1];
|
||||||
|
else merged.push([...seg]);
|
||||||
|
}
|
||||||
|
sky.length = 0;
|
||||||
|
sky.push(...merged);
|
||||||
|
}
|
||||||
|
const w0 = Math.max(...placed.map((p) => p[0] + p[2]));
|
||||||
|
const h0 = Math.max(...placed.map((p) => p[1] + p[3]));
|
||||||
|
return [placed, w0, h0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function icPackSweep(sizes, gap) {
|
||||||
|
const used = sizes.reduce((s, [w, h]) => s + w * h, 0);
|
||||||
|
const lo = Math.max(...sizes.map((s) => s[0])) + gap;
|
||||||
|
const hi = sizes.reduce((s, [w]) => s + w, 0) + gap * sizes.length;
|
||||||
|
const idx = sizes.map((_, i) => i);
|
||||||
|
const orders = [
|
||||||
|
idx,
|
||||||
|
[...idx].sort((a, b) => sizes[b][1] - sizes[a][1] || a - b),
|
||||||
|
[...idx].sort((a, b) => sizes[b][0] - sizes[a][0] || a - b),
|
||||||
|
[...idx].sort((a, b) =>
|
||||||
|
sizes[b][0] * sizes[b][1] - sizes[a][0] * sizes[a][1] || a - b),
|
||||||
|
];
|
||||||
|
// Quantized score for exact backend parity.
|
||||||
|
const q = (v) => Math.floor(v * 1e9 + 0.5);
|
||||||
|
let found = null;
|
||||||
|
for (const order of orders) {
|
||||||
|
const ordered = order.map((i) => sizes[i]);
|
||||||
|
let best = null;
|
||||||
|
for (let step = 0; step < IC_PACK_WIDTH_STEPS; step++) {
|
||||||
|
const width = lo + ((hi - lo) * step) / (IC_PACK_WIDTH_STEPS - 1);
|
||||||
|
const got = icSkylinePack(ordered, width, gap);
|
||||||
|
if (!got) continue;
|
||||||
|
const [placed, w0, h0] = got;
|
||||||
|
const fill = used / (w0 * h0);
|
||||||
|
const aspect = w0 / h0;
|
||||||
|
if (aspect < IC_PACK_ASPECT_MIN || aspect > IC_PACK_ASPECT_MAX) continue;
|
||||||
|
const key = [-q(fill), q(Math.abs(Math.log(aspect))), -q(aspect)];
|
||||||
|
if (best === null || icKeyLess(key, best.key)) {
|
||||||
|
best = { key, placed, w0, h0, order };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best && (!found || icKeyLess(best.key, found.key))) found = best;
|
||||||
|
}
|
||||||
|
if (!found) return null;
|
||||||
|
const boxes = new Array(sizes.length).fill(null);
|
||||||
|
found.order.forEach((slot, i) => { boxes[slot] = found.placed[i]; });
|
||||||
|
return { boxes, w0: found.w0, h0: found.h0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function icKeyLess(a, b) {
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return a[i] < b[i];
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror of the backend's _ic_plan_natural (integer canvas, shared scale <= 1).
|
||||||
|
// Includes the half-gap frame around the sheet, as the backend does.
|
||||||
|
function icPlanNatural(sizes, gap) {
|
||||||
|
const packed = icPackSweep(sizes, gap);
|
||||||
|
if (!packed) return null;
|
||||||
|
const { boxes, w0, h0 } = packed;
|
||||||
|
const alignUp = (v) => Math.max(IC_ALIGN, Math.ceil(v / IC_ALIGN) * IC_ALIGN);
|
||||||
|
// Frame of gap/2 around the whole sheet; 0 when gap is 0.
|
||||||
|
const frame = Math.round(gap / 2);
|
||||||
|
let width = alignUp(w0);
|
||||||
|
let height = alignUp(h0);
|
||||||
|
const out = boxes.map(([x, y, w, h]) => {
|
||||||
|
const bw = Math.max(1, Math.round(w));
|
||||||
|
const bh = Math.max(1, Math.round(h));
|
||||||
|
return [Math.round(x), Math.round(y), bw, bh];
|
||||||
|
});
|
||||||
|
// Expand the canvas by the frame and shift every box inward by it.
|
||||||
|
width += 2 * frame;
|
||||||
|
height += 2 * frame;
|
||||||
|
for (const b of out) {
|
||||||
|
b[0] += frame;
|
||||||
|
b[1] += frame;
|
||||||
|
}
|
||||||
|
return { width, height, boxes: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "noEmbryo.ImageComposer",
|
||||||
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
|
if (nodeData.name !== "Image Composer -noEmbryo") return;
|
||||||
|
|
||||||
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
const result = onNodeCreated
|
||||||
|
? onNodeCreated.apply(this, arguments)
|
||||||
|
: undefined;
|
||||||
|
const node = this;
|
||||||
|
|
||||||
|
const mpWidget = node.widgets.find((w) => w.name === "max_megapixels");
|
||||||
|
const gapWidget = node.widgets.find((w) => w.name === "gap");
|
||||||
|
const bgWidget = node.widgets.find((w) => w.name === "background");
|
||||||
|
|
||||||
|
// Hide the managed widget (canvas + Nodes 2.0).
|
||||||
|
for (const w of [mpWidget]) {
|
||||||
|
if (w) {
|
||||||
|
w.hidden = true;
|
||||||
|
w.options = w.options || {};
|
||||||
|
w.options.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = { thumbs: new Map(), box: null };
|
||||||
|
|
||||||
|
// Register this Composer node for global graph change notifications
|
||||||
|
composerNodes.add(node);
|
||||||
|
// Expose refreshThumbs on the node so the global listener can call it
|
||||||
|
node.refreshThumbs = refreshThumbs;
|
||||||
|
// Clean up registry when node is removed
|
||||||
|
const prevOnRemoved = node.onRemoved;
|
||||||
|
node.onRemoved = function () {
|
||||||
|
composerNodes.delete(node);
|
||||||
|
return prevOnRemoved?.apply(this, arguments);
|
||||||
|
};
|
||||||
|
// Install global graph change listener (once)
|
||||||
|
installGraphChangeListener();
|
||||||
|
|
||||||
|
function connectedSlots() {
|
||||||
|
const slots = [];
|
||||||
|
for (let i = 1; i <= IC_MAX_IMAGES; i++) {
|
||||||
|
const input = node.inputs?.find((inp) => inp.name === `image${i}`);
|
||||||
|
if (input && input.link != null) slots.push(i);
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return only slots that have a valid upstream image path
|
||||||
|
// (not bypassed, not cleared). Used for preview packing to avoid
|
||||||
|
// showing numbered placeholders for disabled/bypassed GetNodes.
|
||||||
|
function validSlots() {
|
||||||
|
return connectedSlots().filter((i) => {
|
||||||
|
const info = upstreamPath(i);
|
||||||
|
return info && info.path;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Growing inputs -----------------------------------------
|
||||||
|
// Keep exactly one trailing free slot: enough connected inputs
|
||||||
|
// to hold every link, plus one empty one to grow into.
|
||||||
|
function syncInputs() {
|
||||||
|
let connected = 0;
|
||||||
|
for (const inp of node.inputs || []) {
|
||||||
|
if (inp.link != null) connected++;
|
||||||
|
}
|
||||||
|
const want = Math.min(IC_MAX_IMAGES, connected + 1);
|
||||||
|
// Remove trailing unconnected inputs beyond the wanted count.
|
||||||
|
while (node.inputs.length > want &&
|
||||||
|
node.inputs[node.inputs.length - 1].link == null) {
|
||||||
|
node.removeInput(node.inputs.length - 1);
|
||||||
|
}
|
||||||
|
// Add free slots until we reach the wanted count.
|
||||||
|
let n = node.inputs.length;
|
||||||
|
while (n < want) {
|
||||||
|
n++;
|
||||||
|
node.addInput(`image${n}`, "IMAGE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevOnConn = nodeType.prototype.onConnectionsChange;
|
||||||
|
nodeType.prototype.onConnectionsChange = function (side, slot, connect) {
|
||||||
|
const r = prevOnConn?.apply(this, arguments);
|
||||||
|
if (side === 1) {
|
||||||
|
syncInputs();
|
||||||
|
refreshThumbs();
|
||||||
|
if (!connect) {
|
||||||
|
// Connection was broken — clean up thumbnail entries
|
||||||
|
// for any inputs that are no longer connected so the
|
||||||
|
// Composer doesn't keep showing the bypassed image.
|
||||||
|
let cleaned = false;
|
||||||
|
for (const [key, entry] of state.thumbs) {
|
||||||
|
const i = Number(key);
|
||||||
|
const inp = node.inputs?.find((w) => w.name === `image${i}`);
|
||||||
|
if (!inp || inp.link == null) {
|
||||||
|
state.thumbs.delete(key);
|
||||||
|
cleaned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleaned) node.setDirtyCanvas?.(true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Thumbnail loading via the serve proxy -------------------
|
||||||
|
function upstreamPath(slotIdx) {
|
||||||
|
const input = node.inputs?.find((inp) => inp.name === `image${slotIdx}`);
|
||||||
|
if (!input || input.link == null) return null;
|
||||||
|
const link = app.graph.links[input.link];
|
||||||
|
if (!link) return null;
|
||||||
|
const src = app.graph._nodes_by_id?.[link.origin_id];
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
console.log(`[ImageComposer] slot ${slotIdx}: src type=${src.type || "?"} ` +
|
||||||
|
`widgets=${(src.widgets || []).map(w => w.name).join(",")}`);
|
||||||
|
|
||||||
|
// Direct connection: LoadImageFromPathEnhanced
|
||||||
|
const result = icExtractFromNode(src);
|
||||||
|
if (result) {
|
||||||
|
console.log(`[ImageComposer] slot ${slotIdx}: direct match path=${result.path}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intermediate nodes (e.g. KJ Set/Get): trace back
|
||||||
|
const traced = icTraceBack(src);
|
||||||
|
if (traced) {
|
||||||
|
console.log(`[ImageComposer] slot ${slotIdx}: traced path=${traced.path}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[ImageComposer] slot ${slotIdx}: no trace result, ` +
|
||||||
|
`checking widgets for paths...`);
|
||||||
|
const fallbackPath = icFindPathInWidgets(src);
|
||||||
|
if (fallbackPath) {
|
||||||
|
console.log(`[ImageComposer] slot ${slotIdx}: fallback path=${fallbackPath}`);
|
||||||
|
return { path: fallbackPath, rotation: 0, crop: null, maxMp: 0,
|
||||||
|
srcNode: src };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return traced;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract path/crop/maxMp from a LoadImageFromPathEnhanced node.
|
||||||
|
function icExtractFromNode(srcNode) {
|
||||||
|
// If the source node is bypassed (mode 4), treat as no valid source.
|
||||||
|
if (srcNode.mode === 4) return null;
|
||||||
|
const pathW = srcNode.widgets?.find((w) => w.name === "image");
|
||||||
|
const cropW = srcNode.widgets?.find((w) => w.name === "crop");
|
||||||
|
if (!pathW) return null;
|
||||||
|
let crop = null, rotation = 0;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(cropW?.value || "{}") || {};
|
||||||
|
rotation = parseInt(data.rotation, 10) || 0;
|
||||||
|
if (data.w > 0 && data.h > 0)
|
||||||
|
crop = { x: +data.x, y: +data.y, w: +data.w, h: +data.h };
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
// The upstream megapixel cap shapes the tensor the backend
|
||||||
|
// receives, so it must shape the packing too.
|
||||||
|
const mpW = srcNode.widgets?.find((w) => w.name === "max_megapixels");
|
||||||
|
const maxMp = Math.max(0, parseFloat(mpW?.value) || 0);
|
||||||
|
return { path: String(pathW.value || "").trim(), rotation, crop,
|
||||||
|
maxMp, srcNode: srcNode };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic fallback: check ALL widgets on a node for any string
|
||||||
|
// that looks like a file path or URL.
|
||||||
|
function icFindPathInWidgets(srcNode) {
|
||||||
|
for (const w of srcNode.widgets || []) {
|
||||||
|
const v = String(w.value || "").trim();
|
||||||
|
if (!v) continue;
|
||||||
|
if (v.startsWith("/") || v.startsWith("http") ||
|
||||||
|
v.endsWith(".png") || v.endsWith(".jpg") ||
|
||||||
|
v.endsWith(".jpeg") || v.endsWith(".webp") ||
|
||||||
|
v.endsWith(".bmp") || v.endsWith(".gif")) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace back through intermediate nodes to find the original
|
||||||
|
// LoadImageFromPathEnhanced source.
|
||||||
|
function icTraceBack(node) {
|
||||||
|
// Strategy 1: follow input links backward through the graph
|
||||||
|
for (const inp of node.inputs || []) {
|
||||||
|
if (inp.link != null) {
|
||||||
|
const l = app.graph.links[inp.link];
|
||||||
|
if (l) {
|
||||||
|
const src = app.graph._nodes_by_id?.[l.origin_id];
|
||||||
|
if (src) {
|
||||||
|
const result = icExtractFromNode(src);
|
||||||
|
if (result) return result;
|
||||||
|
const recursive = icTraceBack(src);
|
||||||
|
if (recursive) return recursive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: KJ GetNode — match key to find SetNode's source
|
||||||
|
const nodeType = node.type || "";
|
||||||
|
if (nodeType === "GetNode" || nodeType.includes("Get")) {
|
||||||
|
const kjResult = icTraceThroughKJ(node);
|
||||||
|
if (kjResult) return kjResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: any widget on this node contains a path
|
||||||
|
const path = icFindPathInWidgets(node);
|
||||||
|
if (path) {
|
||||||
|
return { path, rotation: 0, crop: null, maxMp: 0,
|
||||||
|
srcNode: node };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a GetNode, find the matching SetNode by key,
|
||||||
|
// then trace to the node that feeds the SetNode's input.
|
||||||
|
function icTraceThroughKJ(getNode) {
|
||||||
|
// KJ nodes store the key in widgets[0] (first widget).
|
||||||
|
const keyW = getNode.widgets?.[0];
|
||||||
|
if (!keyW) return null;
|
||||||
|
const key = String(keyW.value ?? "");
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
// Search for a SetNode with matching key
|
||||||
|
for (const nodeId in app.graph._nodes_by_id) {
|
||||||
|
const n = app.graph._nodes_by_id[nodeId];
|
||||||
|
if (n === getNode) continue;
|
||||||
|
if (n.type !== "SetNode" && !n.type.includes("Set")) continue;
|
||||||
|
// Found a SetNode — check if its key matches
|
||||||
|
const setKeyW = n.widgets?.[0];
|
||||||
|
if (!setKeyW) continue;
|
||||||
|
if (String(setKeyW.value ?? "") !== key) continue;
|
||||||
|
// If SetNode is bypassed (mode 4) or its input is disconnected,
|
||||||
|
// the loader is effectively bypassed — don't return stale value.
|
||||||
|
if (n.mode === 4) continue;
|
||||||
|
if (!(n.inputs?.[0]?.link != null)) continue;
|
||||||
|
// Key matches — follow SetNode's input (slot 0) to find source
|
||||||
|
for (const inp of n.inputs || []) {
|
||||||
|
if (inp.link != null) {
|
||||||
|
const l = app.graph.links[inp.link];
|
||||||
|
if (l) {
|
||||||
|
const src = app.graph._nodes_by_id?.[l.origin_id];
|
||||||
|
if (src) {
|
||||||
|
const result = icExtractFromNode(src);
|
||||||
|
if (result) return result;
|
||||||
|
const recursive = icTraceBack(src);
|
||||||
|
if (recursive) return recursive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThumb(slotIdx, entry) {
|
||||||
|
entry.seq = (entry.seq || 0) + 1;
|
||||||
|
const seq = entry.seq;
|
||||||
|
const p = entry.path;
|
||||||
|
if (!p) { entry.img = null; node.setDirtyCanvas?.(true, true); return; }
|
||||||
|
const url = /^https?:\/\//i.test(p)
|
||||||
|
? p
|
||||||
|
: `/noembryo/serve_image?path=${encodeURIComponent(p)}` +
|
||||||
|
`&t=${Date.now()}`;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (entry.seq !== seq) return;
|
||||||
|
// Backend order: ROTATE the full image first, THEN crop —
|
||||||
|
// the crop coords are drawn on the rotated preview, so
|
||||||
|
// they only map correctly onto the rotated image.
|
||||||
|
let result = icRotate(img, entry.rotation);
|
||||||
|
const c = entry.crop;
|
||||||
|
if (c && c.w > 0 && c.h > 0) {
|
||||||
|
const rw = result.width, rh = result.height;
|
||||||
|
const cw = Math.max(1, Math.round(c.w * rw));
|
||||||
|
const ch = Math.max(1, Math.round(c.h * rh));
|
||||||
|
const cx = Math.max(0, Math.min(rw - 1,
|
||||||
|
Math.round(c.x * rw)));
|
||||||
|
const cy = Math.max(0, Math.min(rh - 1,
|
||||||
|
Math.round(c.y * rh)));
|
||||||
|
const cc = document.createElement("canvas");
|
||||||
|
cc.width = cw;
|
||||||
|
cc.height = ch;
|
||||||
|
cc.getContext("2d").drawImage(result, cx, cy, cw, ch,
|
||||||
|
0, 0, cw, ch);
|
||||||
|
result = cc;
|
||||||
|
}
|
||||||
|
// Apply the upstream megapixel cap (downscale-only,
|
||||||
|
// aspect-preserved — mirrors the backend behaviour).
|
||||||
|
const mp = entry.maxMp;
|
||||||
|
if (mp > 0) {
|
||||||
|
const maxPixels = mp * 1024 * 1024;
|
||||||
|
const cur = result.width * result.height;
|
||||||
|
if (cur > maxPixels) {
|
||||||
|
const sc = Math.sqrt(maxPixels / cur);
|
||||||
|
const sc2 = document.createElement("canvas");
|
||||||
|
sc2.width = Math.max(1, Math.round(result.width * sc));
|
||||||
|
sc2.height = Math.max(1, Math.round(result.height * sc));
|
||||||
|
sc2.getContext("2d").drawImage(
|
||||||
|
result, 0, 0, sc2.width, sc2.height);
|
||||||
|
result = sc2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.img = result;
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
if (entry.seq !== seq) return;
|
||||||
|
entry.img = null;
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function icRotate(src, deg) {
|
||||||
|
const d = ((deg % 360) + 360) % 360;
|
||||||
|
if (!d) return src;
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
const quarter = (d / 90) % 4;
|
||||||
|
if (quarter % 2 === 1) { c.width = src.height; c.height = src.width; }
|
||||||
|
else { c.width = src.width; c.height = src.height; }
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
ctx.translate(c.width / 2, c.height / 2);
|
||||||
|
ctx.rotate((d * Math.PI) / 180);
|
||||||
|
ctx.drawImage(src, -src.width / 2, -src.height / 2);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshThumbs() {
|
||||||
|
for (const i of connectedSlots()) {
|
||||||
|
const info = upstreamPath(i);
|
||||||
|
const entry = state.thumbs.get(i) || {};
|
||||||
|
state.thumbs.set(i, entry);
|
||||||
|
if (info && info.path) {
|
||||||
|
const cropKey = `${info.crop ? JSON.stringify(info.crop) : ""}` +
|
||||||
|
`|${info.maxMp}`;
|
||||||
|
if (entry.path !== info.path ||
|
||||||
|
entry.rotation !== info.rotation ||
|
||||||
|
entry.cropKey !== cropKey) {
|
||||||
|
entry.path = info.path;
|
||||||
|
entry.rotation = info.rotation;
|
||||||
|
entry.crop = info.crop;
|
||||||
|
entry.maxMp = info.maxMp;
|
||||||
|
entry.cropKey = cropKey;
|
||||||
|
loadThumb(i, entry);
|
||||||
|
}
|
||||||
|
} else if (!info) {
|
||||||
|
state.thumbs.delete(i);
|
||||||
|
} else if (!info.path) {
|
||||||
|
// Upstream exists but path is empty (loader cleared).
|
||||||
|
// Drop the stale thumbnail so the Composer doesn't
|
||||||
|
// keep showing the last received image.
|
||||||
|
if (entry && entry.path) {
|
||||||
|
entry.path = "";
|
||||||
|
entry.img = null;
|
||||||
|
entry.rotation = 0;
|
||||||
|
entry.crop = null;
|
||||||
|
entry.maxMp = 0;
|
||||||
|
entry.cropKey = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Live preview widget ------------------------------------
|
||||||
|
let allocHeight;
|
||||||
|
const MIN_PREVIEW_H = 80;
|
||||||
|
const MARGIN = 10;
|
||||||
|
|
||||||
|
function boxHeight(widget, widgetY, fallback) {
|
||||||
|
const nodeH = node.size?.[1];
|
||||||
|
const visible = node.widgets?.filter((w) => !w.hidden);
|
||||||
|
const isLast = !!visible && visible[visible.length - 1] === widget;
|
||||||
|
if (nodeH == null || widgetY == null || !isLast) return fallback;
|
||||||
|
return Math.max(MIN_PREVIEW_H, nodeH - widgetY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVueMode = () =>
|
||||||
|
typeof LiteGraph !== "undefined" && !!LiteGraph.vueNodesMode;
|
||||||
|
|
||||||
|
const preview = {
|
||||||
|
name: "composer_preview",
|
||||||
|
type: "noembryo_composer_preview",
|
||||||
|
value: "",
|
||||||
|
serialize: false,
|
||||||
|
options: { serialize: false },
|
||||||
|
computeLayoutSize() {
|
||||||
|
return { minHeight: MIN_PREVIEW_H, maxHeight: 100000, minWidth: 0 };
|
||||||
|
},
|
||||||
|
draw(ctx, _node, widgetWidth, y, H, lowQuality) {
|
||||||
|
const h = boxHeight(this, y, allocHeight ?? H) - 8;
|
||||||
|
const x = MARGIN;
|
||||||
|
const nodeW = _node?.size?.[0];
|
||||||
|
const effWidth =
|
||||||
|
!isVueMode() && nodeW ? Math.min(widgetWidth, nodeW) : widgetWidth;
|
||||||
|
const w = effWidth - MARGIN * 2;
|
||||||
|
const slots = validSlots();
|
||||||
|
|
||||||
|
// Gather sizes for the packing (thumbs may still be loading —
|
||||||
|
// fall back to 1:1 aspect so the layout is stable).
|
||||||
|
const sizes = slots.map((i) => {
|
||||||
|
const t = state.thumbs.get(i);
|
||||||
|
return t?.img ? [t.img.width, t.img.height] : [64, 64];
|
||||||
|
});
|
||||||
|
const gap = Math.max(0, parseInt(gapWidget?.value, 10) || 0);
|
||||||
|
const plan = slots.length ? icPlanNatural(sizes, gap) : null;
|
||||||
|
|
||||||
|
state.box = null;
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = "#00000033";
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
|
||||||
|
if (!slots.length || !plan) {
|
||||||
|
ctx.fillStyle = "#888";
|
||||||
|
ctx.font = "12px sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText("Connect image inputs", x + w / 2, y + h / 2);
|
||||||
|
ctx.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit the plan inside the preview area, letterboxed.
|
||||||
|
const areaH = h - 4;
|
||||||
|
const s = Math.min(w / plan.width, areaH / plan.height);
|
||||||
|
const pw = plan.width * s, ph = plan.height * s;
|
||||||
|
const px0 = x + (w - pw) / 2, py0 = y + 2 + (areaH - ph) / 2;
|
||||||
|
state.box = { px0, py0, pw, ph, s, plan, slots };
|
||||||
|
|
||||||
|
ctx.fillStyle = IC_BACKGROUNDS[bgWidget?.value] || "#000";
|
||||||
|
ctx.fillRect(px0, py0, pw, ph);
|
||||||
|
|
||||||
|
slots.forEach((slotIdx, i) => {
|
||||||
|
const [bx, by, bw, bh] = plan.boxes[i];
|
||||||
|
const t = state.thumbs.get(slotIdx);
|
||||||
|
const drawX = px0 + bx * s, drawY = py0 + by * s;
|
||||||
|
// background for slots whose thumb hasn't loaded
|
||||||
|
if (!t?.img) {
|
||||||
|
ctx.fillStyle = "#333";
|
||||||
|
ctx.fillRect(drawX, drawY, bw * s, bh * s);
|
||||||
|
ctx.fillStyle = "#888";
|
||||||
|
ctx.font = "10px sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(`${slotIdx}`, drawX + (bw * s) / 2,
|
||||||
|
drawY + (bh * s) / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fit inside the slot, never enlarge (natural sizing).
|
||||||
|
const scale = Math.min((bw * s) / t.img.width,
|
||||||
|
(bh * s) / t.img.height, 1);
|
||||||
|
const tw = t.img.width * scale, th = t.img.height * scale;
|
||||||
|
ctx.drawImage(t.img, drawX, drawY, tw, th);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sheet size pill
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
||||||
|
ctx.font = "10px sans-serif";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.textBaseline = "alphabetic";
|
||||||
|
const label = `${plan.width} x ${plan.height} px`;
|
||||||
|
const tw = ctx.measureText(label).width;
|
||||||
|
ctx.fillRect(x + 2, y + h - 16, tw + 8, 14);
|
||||||
|
ctx.fillStyle = "#ddd";
|
||||||
|
ctx.fillText(label, x + 6, y + h - 5);
|
||||||
|
ctx.restore();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(preview, "computedHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get() { return undefined; },
|
||||||
|
set(v) { allocHeight = v; },
|
||||||
|
});
|
||||||
|
Object.defineProperty(preview, "width", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => undefined,
|
||||||
|
set: () => {},
|
||||||
|
});
|
||||||
|
node.addCustomWidget(preview);
|
||||||
|
|
||||||
|
// Live refresh when widgets change.
|
||||||
|
for (const w of [gapWidget, bgWidget]) {
|
||||||
|
if (w) {
|
||||||
|
const prev = w.callback;
|
||||||
|
w.callback = function () {
|
||||||
|
const r = prev?.apply(this, arguments);
|
||||||
|
refreshThumbs();
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch KJ Get nodes: when their value changes, refresh thumbnails.
|
||||||
|
for (const i of connectedSlots()) {
|
||||||
|
const input = node.inputs?.find((inp) => inp.name === `image${i}`);
|
||||||
|
if (!input || input.link == null) continue;
|
||||||
|
const link = app.graph.links[input.link];
|
||||||
|
if (!link) continue;
|
||||||
|
const src = app.graph._nodes_by_id?.[link.origin_id];
|
||||||
|
if (!src) continue;
|
||||||
|
const srcType = src.type || "";
|
||||||
|
if (!srcType.includes("KJ")) continue;
|
||||||
|
for (const w of src.widgets || []) {
|
||||||
|
const prev = w.callback;
|
||||||
|
w.callback = function () {
|
||||||
|
const r = prev?.apply(this, arguments);
|
||||||
|
refreshThumbs();
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch upstream nodes: crop/path/rotation edits refresh instantly.
|
||||||
|
// The Composer's own onDrawBackground runs on every canvas redraw,
|
||||||
|
// so polling there is cheap (string compares) and always fires —
|
||||||
|
// unlike hooks on the upstream node, which newer ComfyUI versions
|
||||||
|
// may simply never call.
|
||||||
|
const prevBg = node.onDrawBackground;
|
||||||
|
node.onDrawBackground = function () {
|
||||||
|
const r = prevBg?.apply(this, arguments);
|
||||||
|
let changed = false;
|
||||||
|
// Safety net: clean up thumbnail entries for any
|
||||||
|
// inputs that are no longer connected (e.g. bypassed).
|
||||||
|
for (const [key, entry] of state.thumbs) {
|
||||||
|
const i = Number(key);
|
||||||
|
const input = node.inputs?.find((inp) => inp.name === `image${i}`);
|
||||||
|
if (!input || input.link == null) {
|
||||||
|
state.thumbs.delete(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const i of connectedSlots()) {
|
||||||
|
const cur = upstreamPath(i);
|
||||||
|
const entry = state.thumbs.get(i);
|
||||||
|
if (!cur) {
|
||||||
|
// Upstream invalid (bypassed, broken trace): clear
|
||||||
|
// the thumbnail but keep the entry so a reconnect
|
||||||
|
// can reload it. Only clear if we had a real image
|
||||||
|
// — avoids wiping during transient null returns.
|
||||||
|
if (entry && entry.path && entry.img) {
|
||||||
|
entry.path = "";
|
||||||
|
entry.img = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Upstream cleared (empty path): only clear the thumbnail
|
||||||
|
// when we previously had a real path — avoids wiping
|
||||||
|
// during transient states where upstreamPath returns ""
|
||||||
|
// momentarily (graph rebuilds, node moves, etc.).
|
||||||
|
if (!cur.path) {
|
||||||
|
if (entry && entry.path) {
|
||||||
|
entry.path = "";
|
||||||
|
entry.img = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cropKey = `${cur.crop ? JSON.stringify(cur.crop) : ""}` +
|
||||||
|
`|${cur.maxMp}`;
|
||||||
|
if (entry && (entry.path !== cur.path ||
|
||||||
|
entry.rotation !== cur.rotation ||
|
||||||
|
entry.cropKey !== cropKey)) {
|
||||||
|
entry.path = cur.path;
|
||||||
|
entry.rotation = cur.rotation;
|
||||||
|
entry.crop = cur.crop;
|
||||||
|
entry.maxMp = cur.maxMp;
|
||||||
|
entry.cropKey = cropKey;
|
||||||
|
loadThumb(i, entry);
|
||||||
|
changed = true;
|
||||||
|
} else if (!entry) {
|
||||||
|
const ne = { path: cur.path, rotation: cur.rotation,
|
||||||
|
crop: cur.crop, maxMp: cur.maxMp, cropKey, img: null };
|
||||||
|
state.thumbs.set(i, ne);
|
||||||
|
loadThumb(i, ne);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) node.setDirtyCanvas?.(true, true);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
syncInputs();
|
||||||
|
refreshThumbs();
|
||||||
|
// Set up KJ node callbacks now that inputs are synced.
|
||||||
|
for (const i of connectedSlots()) {
|
||||||
|
const input = node.inputs?.find((inp) => inp.name === `image${i}`);
|
||||||
|
if (!input || input.link == null) continue;
|
||||||
|
const link = app.graph.links[input.link];
|
||||||
|
if (!link) continue;
|
||||||
|
const src = app.graph._nodes_by_id?.[link.origin_id];
|
||||||
|
if (!src) continue;
|
||||||
|
const srcType = src.type || "";
|
||||||
|
if (!srcType.includes("KJ")) continue;
|
||||||
|
for (const w of src.widgets || []) {
|
||||||
|
if (w.callback) continue; // already set up
|
||||||
|
const prev = w.callback;
|
||||||
|
w.callback = function () {
|
||||||
|
const r = prev?.apply(this, arguments);
|
||||||
|
refreshThumbs();
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const prevOnConfigure = node.onConfigure;
|
||||||
|
node.onConfigure = function () {
|
||||||
|
const r = prevOnConfigure?.apply(this, arguments);
|
||||||
|
setTimeout(() => { syncInputs(); refreshThumbs(); }, 0);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "noEmbryo.LoadImageFromPath",
|
name: "noEmbryo.LoadImageFromPath",
|
||||||
|
|
||||||
Reference in New Issue
Block a user