This commit is contained in:
noembryo
2026-09-29 15:54:48 +03:00
parent b2191d6680
commit db844fcf2a
8 changed files with 2632 additions and 701 deletions
+1 -2
View File
@@ -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
+1 -3
View File
@@ -17,7 +17,6 @@ You can access them through "Add node > noEmbryo" submenu.
--- ---
## Json Prompt Loader ## Json Prompt Loader
![Example](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/JsonLoader.png) ![Example](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/JsonLoader.png)
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
![H3MotionContextClipStitcher](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/H3MotionContextClipStitcher.png) ![H3MotionContextClipStitcher](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/H3MotionContextClipStitcher.png)
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
![H3MotionContextClipPurge.png](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/refs/heads/master/stuff/H3MotionContextClipPurge.png) ![H3MotionContextClipPurge.png](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/refs/heads/master/stuff/H3MotionContextClipPurge.png)
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**
+307 -5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+214 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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",