diff --git a/.gitignore b/.gitignore
index 75f178b..38bd9a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,8 +2,7 @@
/XTRA
/__pycache__
/.idea
-/js
/TermList*.json
/___backup.pyw
-/load_image_from_dir.py
-/ComfyUI-noEmbryo.iml
\ No newline at end of file
+/ComfyUI-noEmbryo.iml
+*.pyc
diff --git a/README.md b/README.md
index 6bd0da4..45bdc66 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,6 @@ You can access them through "Add node > noEmbryo" submenu.
---
## 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.
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.
@@ -103,21 +102,20 @@ Built as a much more enhanced version of [Load Image From Path (Enhanced)](https
---
## Resolution Scale
-
+
A simple node that outputs the resolution of an image using the dimensions of an input image or some custom user-defined dimensions, using a Scale Factor.
If there is an input image connected, setting either `width` or `height` to 0 will use the other dimension to scale the image (but always multiple of 4).
---
## Regex Text Chopper
-
+
A node that "chops" a text using a regular expression and outputs the chopped parts of the text.
---
## H3 Motion Context Clip Stitcher
-
-
+
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.
@@ -147,8 +145,8 @@ No quality loss, like when trying to concatenate encoded videos.
---
## 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.
- **Controls**
@@ -183,7 +181,7 @@ This node can save the current workflow to a `.json` file, every time a generati
**The PromptTermList nodes are now obsolete, and can mostly be replaced by the [Json Prompt Loader](#json-prompt-loader) node.
I won't remove them for compatibility reasons, but I would recommend using the [JsonPromptLoader](#json-prompt-loader) node instead.**
-
+
These are some nodes that help with the creation of Prompts inside [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
### Usage
diff --git a/load_image_from_path.py b/image_nodes.py
similarity index 69%
rename from load_image_from_path.py
rename to image_nodes.py
index a6d683c..3bd52be 100644
--- a/load_image_from_path.py
+++ b/image_nodes.py
@@ -1,5 +1,6 @@
import hashlib
import io
+import math
import os
import json
import shutil
@@ -12,7 +13,6 @@ from urllib.error import URLError
from PIL import (Image, ImageOps, ImageSequence, ImageFile, UnidentifiedImageError, )
import numpy as np
import torch
-
import folder_paths
from aiohttp import web
from server import PromptServer
@@ -94,9 +94,13 @@ def _pillow(fn, arg):
return x
-def _pil_to_image_mask(img: 'Image.Image | Iterable[Image.Image]',
- output_image: 'list[torch.Tensor] | None',
- output_mask: 'list[torch.Tensor] | None'):
+def _pil_to_image_mask(img, output_image, output_mask):
+ """
+
+ :type img: Image.Image | Iterable[Image.Image]
+ :type output_image: list[torch.Tensor] | None
+ :type output_mask: list[torch.Tensor] | None
+ """
output_images = []
output_masks = []
w, h = None, None
@@ -278,7 +282,7 @@ class LoadImageFromPathEnhanced:
" and height are connected, when set (not 0), and"
" it overrides max_megapixels.", }), }, }
- CATEGORY = "noEmbryo"
+ CATEGORY = "noEmbryo/Image"
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
RETURN_NAMES = ("IMAGE", "MASK", "path")
FUNCTION = "load_image_enhanced"
@@ -449,6 +453,304 @@ class LoadImageFromPathEnhanced:
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
@web.middleware
async def clipspace_resolver_middleware(request, handler):
diff --git a/minimax.py b/minimax.py
new file mode 100644
index 0000000..9a21fb7
--- /dev/null
+++ b/minimax.py
@@ -0,0 +1,1310 @@
+"""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 gc
+import glob
+import inspect
+import logging
+import os
+import re
+
+import torch
+import comfy.model_management
+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.
+
+ Large latents (1MP and up) go through an explicit tiled decode with
+ conservative tile sizes instead of vae.decode()'s adaptive heuristics,
+ which can pick tiles that are far too aggressive and abort the native
+ VAE kernels. decode_tiled is introspected so only supported kwargs are
+ passed; small latents just use the plain decode path.
+ """
+ decode_fn = vae.decode
+ kwargs = {}
+ # video_latent is [B,C,T,H,W] or [C,T,H,W]; the spatial size is the last
+ # two dims in either case.
+ spatial = tuple(int(v) for v in video_latent.shape[-2:])
+ latent_hw = spatial[0] * spatial[1]
+ # The latent is ~8x downscaled from the pixel resolution, so ~1MP of
+ # video corresponds to a latent spatial product around 16k pixels.
+ tiled = getattr(vae, "decode_tiled", None)
+ if callable(tiled) and latent_hw is not None and latent_hw >= 128 * 96:
+ try:
+ sig = inspect.signature(tiled)
+ supported = set(sig.parameters)
+ except (TypeError, ValueError):
+ supported = set()
+ if supported:
+ # Mirror comfy-core's VAEDecodeTiled defaults, then scale the
+ # spatial tiles down for very large latents so per-tile memory
+ # stays bounded on 1MP+ latents.
+ kwargs = {"tile_x": 256, "tile_y": 256, "tile_t": 64, "overlap": 64}
+ if latent_hw >= 128 * 96: # ~1MP+ of video space: shrink tiles
+ kwargs.update(tile_x=192, tile_y=192)
+ kwargs = {k: v for k, v in kwargs.items() if k in supported}
+ decode_fn = tiled
+ log_.info("H3 clip stitcher: using decode_tiled %s for %dx%d latent.",
+ kwargs, spatial[0], spatial[1])
+ try:
+ images = decode_fn(video_latent, **kwargs)
+ except TypeError: # Signature mismatch fallback: plain decode.
+ 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(audio.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
+
+
+# --- Texture ratchet correction (ported from ComfyUI-Hand-Tie-Clips/latents.py) ---
+# The texture ratchet: high-band noise/grain increases monotonically at each join.
+# The statistic is band_ratio = high-band std / total std.
+# Measured across a chain it goes 0.3643 -> 0.3673 -> 0.3702 (monotone increase).
+# The fix: match_band rescales only the high-frequency band to a target ratio.
+
+def _band_gauss1d(sigma, device, dtype):
+ r = max(1, int(round(3.0 * float(sigma))))
+ x = torch.arange(-r, r + 1, dtype=torch.float32, device=device)
+ k = torch.exp(-(x * x) / (2.0 * float(sigma) ** 2))
+ return (k / k.sum()).to(dtype)
+
+
+def _band_split(t, sigma=2.0):
+ """Separable Gaussian low/high split over the last two dims. -> (lo, hi).
+
+ Returns None when the tensor has no spatial extent to speak of.
+ Replicate padding, not reflect: a latent's spatial dims are small and
+ reflect needs the pad to be smaller than the dimension.
+ """
+ if t.dim() < 2 or t.shape[-1] < 8 or t.shape[-2] < 8:
+ return None
+ # noinspection PyUnresolvedReferences
+ import torch.nn.functional as f
+ h, w = int(t.shape[-2]), int(t.shape[-1])
+ flat = t.reshape(-1, 1, h, w)
+ k = _band_gauss1d(sigma, t.device, t.dtype)
+ pad = k.numel() // 2
+ lo = f.conv2d(f.pad(flat, (pad, pad, 0, 0), mode="replicate"),
+ k.view(1, 1, 1, -1))
+ lo = f.conv2d(f.pad(lo, (0, 0, pad, pad), mode="replicate"),
+ k.view(1, 1, -1, 1))
+ lo = lo.reshape(t.shape)
+ return lo, t - lo
+
+
+def _band_ratio(t, sigma=2.0):
+ """High-band sigma as a fraction of total sigma. -> float, or None.
+
+ This is the statistic the texture ratchet actually moves.
+ Measured across a chain it goes 0.3643 -> 0.3673 -> 0.3702 (monotone increase).
+ """
+ got = _band_split(t, sigma)
+ if got is None:
+ return None
+ lo, hi = got
+ tot = float(t.float().std())
+ if not tot or tot != tot:
+ return None
+ hi_sd = float(hi.float().std())
+ if not hi_sd or hi_sd != hi_sd:
+ return None
+ return hi_sd / tot
+
+
+def _match_band(t, target_ratio, sigma=2.0, clamp=(0.5, 2.0)):
+ """Rescale t's high band so its high-band fraction becomes `target_ratio`.
+
+ -> (tensor, k), or (t, None) when the tensor has no bands to match.
+
+ Only the high half is scaled, so the low-frequency structure that carries
+ the scene is bit-identical and the correction is one scalar. It cannot blur,
+ sharpen unevenly, or invent detail; the worst it can do is get the gain
+ wrong, which is why `clamp` exists.
+
+ The fraction is against the tensor's own sigma, so restoring the ratio
+ does move total sigma a little. That is deliberate: the ratio is the drifting
+ statistic and sigma is the one that lies.
+ """
+ got = _band_split(t, sigma)
+ if got is None or not target_ratio:
+ return t, None
+ lo, hi = got
+ cur_hi = float(hi.float().std())
+ if not cur_hi or cur_hi != cur_hi:
+ return t, None
+
+ # The naive `k = target * sigma / hi_sigma` is wrong, and quietly so:
+ # scaling the high band changes the sigma it is a fraction OF, so the
+ # target moves as you apply it. Measured, it undershot by 5% on a
+ # latent-shaped tensor -- a correction that silently does most, but not
+ # all, of its job is the worst kind to ship.
+ #
+ # First guess solves the fixed point assuming lo and hi are orthogonal:
+ # k*H / sqrt(L^2 + k^2*H^2) = r, so k = r*L / (H*sqrt(1 - r^2)) ...
+ r = min(float(target_ratio), 0.999)
+ lo_sd = float(_band_split(t, 2.0)[0].float().std()) if _band_split(t, 2.0) else 0.0
+ if not lo_sd:
+ return t, None
+ k = (r * lo_sd) / (cur_hi * max(1e-6, (1.0 - r * r) ** 0.5))
+ # ... then refine against the statistic as actually measured, because a
+ # difference of Gaussians is not an exact orthogonal projection. Two or
+ # three passes converge, and a latent is small enough that this is free.
+ for _ in range(4):
+ k = min(max(k, clamp[0]), clamp[1])
+ got_r = _band_ratio(lo + hi * k, 2.0)
+ if not got_r:
+ break
+ if abs(got_r - r) <= 1e-4 * max(r, 1e-6):
+ break
+ k *= r / got_r
+ k = min(max(k, clamp[0]), clamp[1])
+ return lo + hi * k, k
+
+
+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
+
+
+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
+
+
+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 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/MiniMax H3"
+ 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
+
+ live_index = int(first_clip)
+ if latent is not None:
+ if files:
+ # The last on-disk file is presumed to be the live latent's own
+ # saved duplicate; drop it (with a warning) and renumber.
+ log_.warning("H3 clip stitcher: dropping on-disk clip %05d; it is "
+ "presumed to be the live latent's duplicate.",
+ files[-1][0])
+ files = files[:-1]
+ live_index = files[-1][0] + 1 if files else 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
+ # Keep VRAM/RAM flat across clips: free the freshly cached decode
+ # blocks so a long chain (or a big 1MP clip) cannot accumulate.
+ gc.collect()
+ comfy.model_management.soft_empty_cache()
+
+ 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))
+
+ # # Clamp first clip for consistency (tone compensation is now handled
+ # # by H3ClipRefiner node placed inline between sampler and Save Latent)
+ # if pos == 0:
+ # images = images.clamp(0.0, 1.0)
+
+ 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
+
+
+# --- Contrast measurement helpers for inline H3ClipRefiner ---
+
+def _contrast_measure_luminance(frames):
+ """Compute per-frame luminance contrast (std of luminance) for [N,H,W,3] frames in 0..1.
+ Returns [N] tensor of contrast values."""
+ # Luminance = 0.2126*R + 0.7152*G + 0.0722*B
+ lum = (frames[..., 0] * 0.2126 + frames[..., 1] * 0.7152 + frames[..., 2] * 0.0722)
+ # Contrast = std of luminance per frame
+ return lum.reshape(lum.shape[0], -1).std(dim=1)
+
+
+def _contrast_correct_frames(frames, head_contrast, tail_contrast, strength):
+ """Apply contrast correction to frames to undo the contrast drift.
+
+ The denoiser typically changes contrast from head to tail (usually increases it).
+ We measure the contrast ratio (tail/head) and scale luminance to bring tail
+ contrast back to head level.
+
+ frames: [N,H,W,3] in 0..1
+ head_contrast: mean contrast of first N frames
+ tail_contrast: mean contrast of last N frames
+ strength: 0..1, how much of the correction to apply (1.0 = full correction to head level)
+ Returns corrected frames.
+ """
+ if head_contrast <= 1e-6 or tail_contrast <= 1e-6:
+ return frames
+
+ # Contrast ratio: tail/head.
+ # ratio > 1 -> contrast increases (degradation), need to reduce
+ # ratio < 1 -> contrast drops, need to boost
+ ratio = tail_contrast / head_contrast
+
+ # Target correction factor: 1/ratio brings tail contrast to head level.
+ # Interpolate between 1.0 (no correction) and 1/ratio (full correction).
+ correction = 1.0 + (1.0 / ratio - 1.0) * strength
+ correction = max(0.5, min(2.0, correction)) # clamp for safety
+
+ # Apply contrast correction in luminance space
+ # Convert to luminance, scale around mean, convert back
+ lum = (frames[..., 0] * 0.2126 + frames[..., 1] * 0.7152 + frames[..., 2] * 0.0722)
+ lum_mean = lum.mean(dim=(1, 2), keepdim=True)
+ lum_corrected = lum_mean + (lum - lum_mean) * correction
+
+ # Reconstruct RGB: preserve chroma by scaling RGB proportionally
+ # This is an approximation - proper way would be Lab space
+ scale = torch.where(lum > 1e-6, lum_corrected / lum, torch.ones_like(lum))
+ scale = scale.unsqueeze(-1)
+ corrected = frames * scale
+
+ return corrected.clamp_(0.0, 1.0)
+
+
+class H3ClipRefiner:
+ """ Inline texture-ratchet correction node for H3 Motion Context clips.
+
+ Place this node BETWEEN the sampler (SamplerCustomAdvanced) and the
+ H3 Motion Context Save Latent node. It operates on the LATENT level
+ (no VAE decode/encode needed for measurement) to fix the texture ratchet:
+ high-band noise/grain that increases monotonically at each join.
+
+ The degradation is a "texture ratchet": high-band noise/grain increases
+ monotonically at each join (+4.2% mid-band per join). The statistic is
+ band_ratio = high-band std / total std. Measured across a chain it goes
+ 0.3643 -> 0.3673 -> 0.3702 (monotone increase).
+
+ WORKFLOW:
+ 1. First run: set target_ratio=0.0 (measure mode). The node measures the
+ band_ratio (high-band std / total std) of the video latent and logs it
+ (e.g., "band_ratio=0.3673"). The latent passes through unchanged.
+ 2. Note the reported band_ratio from the FIRST clip (e.g., 0.3643).
+ 3. Subsequent runs: set target_ratio to the first clip's band_ratio (e.g.,
+ 0.3643). The node applies match_band to rescale the high-band so the
+ band_ratio matches the target, fixing the texture ratchet.
+
+ The correction uses match_band: rescales ONLY the high-frequency band to
+ match the target band_ratio. Low-frequency structure (scene content) is
+ bit-identical preserved. No VAE decode/encode needed for the correction.
+
+ TO MAKE IT LESS GRAINY: If a clip measures at 0.3917 and you want it less
+ grainy, set target_ratio to a LOWER value (e.g., 0.3800). The lower the
+ target_ratio, the more the high-band grain is reduced.
+ """
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {"required": {"latent": ("LATENT", {
+ "tooltip": "The generated AV latent from your H3 sampler "
+ "(SamplerCustomAdvanced output)."}),
+ "target_ratio": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0,
+ "step": 0.0001,
+ "tooltip": "0.0 = measure only (report band_ratio, pass latent through).\n"
+ ">0.0 = target band_ratio to match. Set to the first clip's "
+ "band_ratio (e.g., 0.3643) to fix the texture ratchet.\n"
+ "To make it LESS grainy, set target_ratio LOWER than the measured "
+ "band_ratio (e.g., measured 0.3917 -> set 0.3800)."}),
+ "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0,
+ "step": 0.01,
+ "tooltip": "Correction strength. 0.0 = measure only (pass through).\n"
+ "1.0 = full correction to target_ratio.\n"
+ ">1.0 = over-correction (stronger grain reduction).\n"
+ "Use >1.0 if target_ratio alone doesn't reduce grain enough."}),
+ },
+ "optional": {"reference_latent": ("LATENT", {
+ "tooltip": "Optional: reference latent (e.g., first clip) to measure "
+ "target_ratio from automatically. If connected, target_ratio "
+ "is ignored and measured from this latent."})},
+ }
+
+ RETURN_TYPES = ("LATENT", "STRING")
+ RETURN_NAMES = ("latent", "report")
+ FUNCTION = "refine"
+ CATEGORY = "noEmbryo/MiniMax H3"
+ DESCRIPTION = ("Inline texture-ratchet corrector for H3 Motion Context clips.\nPlace "
+ "between SamplerCustomAdvanced and H3 Motion Context Save Latent.\n"
+ "Fixes the texture ratchet: high-band noise/grain that increases at "
+ "each join.\ntarget_ratio=0: measures band_ratio "
+ "(high-band std / total std), passes latent through.\n"
+ "target_ratio>0: applies match_band to rescale high-band to target "
+ "ratio.\nstrength=0: measure only. strength=1: full correction. >1: "
+ "over-correction.\nRun first clip with target_ratio=0 to measure its "
+ "band_ratio, then set target_ratio to that value for all subsequent "
+ "clips.\nTo make it LESS grainy, set target_ratio LOWER than measured "
+ "band_ratio.\nUse strength>1.0 for over-correction if needed.")
+
+ # noinspection PyUnusedLocal
+ @classmethod
+ def IS_CHANGED(cls, latent, target_ratio, strength,
+ reference_latent=None):
+ # noinspection PyBroadException
+ try:
+ import hashlib
+ latent_bytes = str(latent).encode()
+ latent_hash = hashlib.md5(latent_bytes).hexdigest()[:16]
+ return latent_hash, float(target_ratio), float(strength)
+ except Exception:
+ return float("NaN")
+
+ @staticmethod
+ def refine(latent, target_ratio, strength,
+ reference_latent=None):
+ # Extract video latent from AV latent
+ if not isinstance(latent, dict) or "samples" not in latent:
+ raise ValueError("H3ClipRefiner: expected a latent dict with 'samples' key")
+
+ samples = latent["samples"]
+ if hasattr(samples, "unbind"):
+ parts = list(samples.unbind())
+ elif isinstance(samples, (tuple, list)):
+ parts = list(samples)
+ else:
+ raise ValueError("H3ClipRefiner: expected AV latent with video+audio")
+
+ if len(parts) < 2:
+ raise ValueError("H3ClipRefiner: latent has no audio stream")
+
+ video_latent = parts[0].detach().to("cpu", copy=True).contiguous()
+ audio_latent = parts[1].detach().to("cpu", copy=True).contiguous()
+
+ # Handle video latent shape: [B,C,T,H,W] or [C,T,H,W]
+ if video_latent.dim() == 5:
+ video_latent = video_latent.squeeze(0) # [C,T,H,W]
+ # Now [C,T,H,W] - we need to process each channel/frame
+ # The band functions work on [..., H, W] so we process per channel-frame
+
+ # Measure band_ratio of the video latent
+ # Flatten batch/channel/time dims for band_ratio measurement
+ # video_latent is [C, T, H, W] -> reshape to [C*T, H, W] for band_ratio
+ c, t, h, w = video_latent.shape
+ flat_latent = video_latent.reshape(c * t, h, w)
+
+ # Measure band_ratio with improved averaging over multiple sigma values
+ # This gives a more robust measurement by averaging over multiple scales
+ # Use hardcoded best defaults: sigma=2.0, clamp=(0.5, 2.0)
+ sigmas = [1.0, 2.0, 3.0]
+ band_ratios = []
+ for s in sigmas:
+ br = _band_ratio(flat_latent, sigma=s)
+ if br is not None:
+ band_ratios.append(br)
+
+ if not band_ratios:
+ raise ValueError("H3ClipRefiner: could not measure band_ratio (latent too small?)")
+
+ band_ratio = sum(band_ratios) / len(band_ratios)
+
+ # Determine target_ratio
+ if reference_latent is not None:
+ # Measure target_ratio from reference latent
+ if not isinstance(reference_latent, dict) or "samples" not in reference_latent:
+ raise ValueError("H3ClipRefiner: reference_latent must be an AV latent dict")
+ ref_samples = reference_latent["samples"]
+ if hasattr(ref_samples, "unbind"):
+ ref_parts = list(ref_samples.unbind())
+ elif isinstance(ref_samples, (tuple, list)):
+ ref_parts = list(ref_samples)
+ else:
+ raise ValueError("H3ClipRefiner: reference_latent must be AV latent")
+ if len(ref_parts) < 2:
+ raise ValueError("H3ClipRefiner: reference_latent has no audio stream")
+ ref_video = ref_parts[0].detach().to("cpu", copy=True).contiguous()
+ if ref_video.dim() == 5:
+ ref_video = ref_video.squeeze(0)
+ rc, rt, rh, rw = ref_video.shape
+ ref_flat = ref_video.reshape(rc * rt, rh, rw)
+
+ # Measure reference band_ratio with same averaging
+ ref_ratios = []
+ for s in [1.0, 2.0, 3.0]:
+ br = _band_ratio(ref_flat, sigma=s)
+ if br is not None:
+ ref_ratios.append(br)
+ if not ref_ratios:
+ raise ValueError("H3ClipRefiner: could not measure target_ratio from "
+ "reference_latent")
+ target_ratio = sum(ref_ratios) / len(ref_ratios)
+ log_.info("H3ClipRefiner: measured target_ratio=%.4f from reference_latent",
+ target_ratio)
+
+ # Log the measurement
+ log_.info("H3ClipRefiner: clip band_ratio=%.4f (avg of %d sigmas), target_ratio=%.4f",
+ band_ratio, len(band_ratios), target_ratio if target_ratio > 0 else 0.0)
+
+ report = (f"H3ClipRefiner: band_ratio={band_ratio:.4f}"
+ f"{f', target_ratio={target_ratio:.4f}' if target_ratio > 0 else ''}")
+
+ if target_ratio <= 0.0:
+ # Measure only: report band_ratio, pass latent through
+ report += (" [MEASURE ONLY - latent passed through. Set target_ratio to this "
+ "clip's band_ratio to fix texture ratchet.]")
+ return latent, report
+
+ # Apply match_band to rescale high-band to target_ratio
+ # Use hardcoded best defaults: sigma=2.0, clamp=(0.5, 2.0)
+ corrected_flat, gain = _match_band(flat_latent, target_ratio, sigma=2.0,
+ clamp=(0.5, 2.0))
+
+ if gain is None:
+ report += " [MATCH_BAND FAILED - latent passed through unchanged]"
+ log_.warning("H3ClipRefiner: match_band failed, passing latent through unchanged")
+ return latent, report
+
+ # Apply strength: interpolate between original and corrected
+ # strength=0: original, strength=1: fully corrected, >1: over-correction
+ if strength <= 0.0:
+ # Measure only
+ report += (" [MEASURE ONLY - latent passed through. Set target_ratio to this "
+ "clip's band_ratio to fix texture ratchet.]")
+ return latent, report
+ elif strength < 1.0:
+ corrected_flat = flat_latent + (corrected_flat - flat_latent) * strength
+ effective_gain = 1.0 + (gain - 1.0) * strength
+ else:
+ # strength >= 1.0: full correction or over-correction
+ # Extrapolate beyond the corrected latent for over-correction
+ corrected_flat = flat_latent + (corrected_flat - flat_latent) * strength
+ effective_gain = 1.0 + (gain - 1.0) * strength
+
+ # Reshape back to [C, T, H, W]
+ corrected_video = corrected_flat.reshape(c, t, h, w)
+
+ # Reconstruct AV latent with corrected video + original audio
+ out = dict(latent)
+ # Restore batch dim if original had it
+ if parts[0].dim() == 5:
+ corrected_video = corrected_video.unsqueeze(0)
+ out["samples"] = _AVStreamPair(corrected_video, audio_latent)
+
+ report += (f" [CORRECTED - gain={effective_gain:.4f},"
+ f" band_ratio {band_ratio:.4f} -> {target_ratio:.4f}, "
+ f"strength={strength:.2f}]")
+ log_.info("H3ClipRefiner: applied match_band, gain=%.4f, "
+ "band_ratio %.4f -> %.4f, strength=%.2f",
+ effective_gain, band_ratio, target_ratio, strength)
+
+ return out, report
+
+
+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/MiniMax H3"
+ 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,)
+
+
+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/MiniMax H3"
+ 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 H3AVLatentFromVideo:
+ """ Encode loaded video frames, or wrap an already-encoded latent, into an
+ H3 Motion Context AV latent suitable for saving.
+ """
+ H3_FPS = 24.0
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {"required": {
+ "video_vae": ("VAE", {
+ "tooltip": "MiniMax H3 video VAE (FP16 or INT8 ConvRot)."}),
+ "audio_vae": ("VAE", {
+ "tooltip": "MiniMax H3 audio VAE FP32."}),
+ "source_fps": ("FLOAT", {
+ "default": 24.0, "min": 1.0, "max": 240.0, "step": 0.001,
+ "tooltip": "The frame rate of the loaded video. Frames are "
+ "resampled to H3's native 24 fps by time-based "
+ "frame picking, so audio stays in sync at any "
+ "source rate."}),
+ },
+ "optional": {
+ "images": ("IMAGE", {
+ "tooltip": "The whole video as frames (e.g. from VHS Load Video). "
+ "Leave un-connected when using the latent input."}),
+ "latent": ("LATENT", {
+ "tooltip": "Optional alternative to images. Accepts either a "
+ "nested AV latent from LTXVConcatAVLatent (its audio "
+ "stream is used directly) or an already-encoded H3 "
+ "video LATENT from VAE Encode. A standard [B,C,H,W] "
+ "latent is wrapped as a one-frame H3 video stream; "
+ "connect AUDIO separately when it has no audio stream."}),
+ "audio": ("AUDIO", {
+ "tooltip": "The video's audio (e.g. from VHS Load Video). "
+ "Leave un-connected for a silent clip. Ignored when "
+ "latent already contains an audio stream."}),
+ },
+ }
+
+ RETURN_TYPES = ("LATENT",)
+ RETURN_NAMES = ("latent",)
+ FUNCTION = "encode"
+ CATEGORY = "noEmbryo/MiniMax H3"
+ DESCRIPTION = ("Encodes a whole video (IMAGE frames + AUDIO) with the MiniMax H3 "
+ "VAEs into an AV latent that can be saved with 'H3 Motion Context "
+ "Save Latent' and stitched into later generations.")
+
+ @staticmethod
+ def _resample_frames(images, source_fps, target_fps):
+ """ Time-based frame picking, mirroring VHS's force_rate behavior.
+ """
+ total = int(images.shape[0])
+ if abs(float(source_fps) - float(target_fps)) < 1e-6:
+ return images
+ duration = total / float(source_fps)
+ out_count = max(1, int(round(duration * float(target_fps))))
+ idx = [min(total - 1, max(0, int(round(k * float(source_fps)
+ / float(target_fps)))))
+ for k in range(out_count)]
+ return images[idx]
+
+ @staticmethod
+ def _is_nested_av_latent(latent):
+ if not isinstance(latent, dict) or "samples" not in latent:
+ return False
+ samples = latent["samples"]
+ return (getattr(samples, "is_nested", False)
+ or isinstance(samples, (tuple, list))
+ or (hasattr(samples, "unbind") and not torch.is_tensor(samples)))
+
+ @staticmethod
+ def _video_from_ordinary_latent(latent):
+ if not isinstance(latent, dict) or "samples" not in latent:
+ raise ValueError("h3_av_latent_from_video: expected a LATENT dict "
+ "with a 'samples' tensor, got %r" % type(latent))
+ samples = latent["samples"]
+ if not torch.is_tensor(samples):
+ raise ValueError("h3_av_latent_from_video: ordinary latent input must "
+ "contain a tensor in 'samples', got %r"
+ % type(samples))
+ if samples.ndim == 4:
+ samples = samples.unsqueeze(2)
+ elif samples.ndim != 5:
+ raise ValueError("h3_av_latent_from_video: ordinary latent samples "
+ "must have shape [B,C,H,W] or [B,C,T,H,W], got %s."
+ % (tuple(samples.shape),))
+ if not torch.is_floating_point(samples):
+ raise ValueError("h3_av_latent_from_video: ordinary latent samples "
+ "are not a float tensor (dtype %s)." % (samples.dtype,))
+ return samples.detach().to("cpu", copy=True).contiguous()
+
+ @staticmethod
+ def _frame_count_from_video_latent(video_latent):
+ """Return the canonical H3 pixel-frame count for a video latent."""
+ latent_t = int(video_latent.shape[2])
+ if latent_t <= 1:
+ return 1
+ if latent_t == 2:
+ return 5
+ return ((latent_t - 2) // 5) * 17 + 5
+
+ @staticmethod
+ def _encode_latent(latent, audio_vae, audio=None):
+ if H3AVLatentFromVideo._is_nested_av_latent(latent):
+ video_latent, audio_latent = _av_from_live_latent(latent)
+ source = "nested AV latent"
+ else:
+ video_latent = H3AVLatentFromVideo._video_from_ordinary_latent(latent)
+ if audio_vae is None:
+ raise ValueError("h3_av_latent_from_video: connect the MiniMax H3 "
+ "audio VAE to 'audio_vae' when using an ordinary "
+ "latent without an audio stream.")
+ if audio is not None:
+ audio = _resample_audio(
+ {"waveform": audio["waveform"][:1],
+ "sample_rate": int(audio["sample_rate"])},
+ int(getattr(audio_vae, "audio_sample_rate", 32000)))
+ audio_latent = audio_vae.encode(
+ audio["waveform"].movedim(1, -1))
+ else:
+ sr = int(getattr(audio_vae, "audio_sample_rate", 32000))
+ frame_count = H3AVLatentFromVideo._frame_count_from_video_latent(
+ video_latent)
+ silence = torch.zeros(
+ 1, 1, max(1, int(round(frame_count
+ / H3AVLatentFromVideo.H3_FPS * sr))))
+ audio_latent = audio_vae.encode(silence.movedim(1, -1))
+ audio_latent = audio_latent.detach().to("cpu", copy=True).contiguous()
+ source = "ordinary latent"
+
+ if int(video_latent.shape[0]) != 1:
+ raise ValueError("h3_av_latent_from_video: H3 Motion Context AV "
+ "latents must have batch size 1, got video shape %s."
+ % (tuple(video_latent.shape),))
+ if int(audio_latent.shape[0]) != 1:
+ raise ValueError("h3_av_latent_from_video: H3 Motion Context AV "
+ "latents must have batch size 1, got audio shape %s."
+ % (tuple(audio_latent.shape),))
+
+ out = {"samples": _AVStreamPair(video_latent, audio_latent)}
+ log_.info("H3 AV Latent from %s: video latent %s, audio latent %s",
+ source, tuple(video_latent.shape),
+ tuple(audio_latent.shape) if audio_latent is not None else None)
+ return (out,)
+
+ @staticmethod
+ def encode(images=None, video_vae=None, audio_vae=None, source_fps=None,
+ audio=None, latent=None):
+ if images is None and latent is None:
+ raise ValueError("h3_av_latent_from_video: connect either images or "
+ "latent.")
+ if images is not None and latent is not None:
+ raise ValueError("h3_av_latent_from_video: connect either images or "
+ "latent, not both.")
+ if latent is not None:
+ return H3AVLatentFromVideo._encode_latent(latent, audio_vae, audio)
+ if images is None:
+ raise ValueError("h3_av_latent_from_video: no frames to encode.")
+ if int(images.shape[0]) < 1:
+ raise ValueError("h3_av_latent_from_video: no frames to encode.")
+ if video_vae is None:
+ raise ValueError("h3_av_latent_from_video: connect the MiniMax H3 "
+ "video VAE to 'video_vae'.")
+ if audio_vae is None:
+ raise ValueError("h3_av_latent_from_video: connect the MiniMax H3 "
+ "audio VAE to 'audio_vae'.")
+
+ frames = H3AVLatentFromVideo._resample_frames(
+ images, source_fps, H3AVLatentFromVideo.H3_FPS)
+ frames = frames.to(video_vae.device if hasattr(video_vae, "device")
+ else "cpu", non_blocking=False)
+ video_latent = video_vae.encode(frames) # [B,C,T,H,W] (batch axis = time)
+ if getattr(video_latent, "ndim", 0) != 5:
+ raise ValueError("h3_av_latent_from_video: video encode returned shape "
+ "%s, expected [B,C,T,H,W]."
+ % (tuple(getattr(video_latent, "shape", ())),))
+ video_latent = video_latent.detach().to("cpu", copy=True).contiguous()
+
+ if audio is not None:
+ audio = _resample_audio(
+ {"waveform": audio["waveform"][:1],
+ "sample_rate": int(audio["sample_rate"])},
+ int(getattr(audio_vae, "audio_sample_rate", 32000)))
+ audio_latent = audio_vae.encode(
+ audio["waveform"].movedim(1, -1)) # [1,C,2,T]
+ audio_latent = audio_latent.detach().to("cpu", copy=True).contiguous()
+ else: # A silent clip still needs an audio stream for the archive format.
+ sr = int(getattr(audio_vae, "audio_sample_rate", 32000))
+ silence = torch.zeros(1, 1, max(1, int(round(frames.shape[0]
+ / H3AVLatentFromVideo.H3_FPS
+ * sr))))
+ audio_latent = audio_vae.encode(silence.movedim(1, -1))
+ audio_latent = audio_latent.detach().to("cpu", copy=True).contiguous()
+
+ out = {"samples": _AVStreamPair(video_latent, audio_latent)}
+ log_.info("H3 AV Latent from Video: %d frames -> video latent %s, "
+ "audio latent %s", int(frames.shape[0]),
+ tuple(video_latent.shape),
+ tuple(audio_latent.shape) if audio_latent is not None else None)
+ return (out,)
+
+
+NODE_CLASS_MAPPINGS = {
+ "H3MotionContextClipStitcher": H3MotionContextClipStitcher,
+ "H3ClipRefiner": H3ClipRefiner,
+ "H3ContextLatentConverter": H3ContextLatentConverter,
+ "H3MotionContextClipPurge": H3MotionContextClipPurge,
+ "H3AVLatentFromVideo": H3AVLatentFromVideo,
+}
+
+NODE_DISPLAY_NAME_MAPPINGS = {
+ "H3MotionContextClipStitcher": "H3 Motion Context Clip Stitcher",
+ "H3ClipRefiner": "H3 Clip Refiner",
+ "H3ContextLatentConverter": "H3 Context Latent Converter",
+ "H3MotionContextClipPurge": "H3 Motion Context Clip Purge",
+ "H3AVLatentFromVideo": "H3 AV Latent from Video",
+}
diff --git a/nodes.py b/nodes.py
index 39b5504..7917c79 100644
--- a/nodes.py
+++ b/nodes.py
@@ -1,11 +1,14 @@
import os, re, io
import json
+import subprocess
+import tempfile
from os.path import realpath, join, dirname, isabs, splitext, basename
from datetime import datetime
import folder_paths
-from .load_image_from_path import LoadImageFromPathEnhanced
-from .stitcher import (H3MotionContextClipStitcher, H3ContextLatentConverter,
- H3MotionContextClipPurge)
+from .image_nodes import LoadImageFromPathEnhanced, ImageComposer
+from .minimax import (H3MotionContextClipStitcher, H3ClipRefiner,
+ H3ContextLatentConverter,
+ H3MotionContextClipPurge, H3AVLatentFromVideo)
MANIFEST = {"name": "noEmbryo Nodes",
"version": (1, 6, 6),
@@ -77,7 +80,7 @@ class JsonPromptLoader:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("Prompt",)
FUNCTION = "run"
- CATEGORY = "noEmbryo"
+ CATEGORY = "noEmbryo/Prompt"
def run(self, json_path, selected_item, variable, custom_prompt):
self.load_data(json_path)
@@ -244,7 +247,7 @@ class PromptTermList:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("Term",)
# OUTPUT_NODE = True
- CATEGORY = "noEmbryo/Term Nodes"
+ CATEGORY = "noEmbryo/Prompt/Term Nodes"
FUNCTION = "run"
def run(self, terms, strength, store_input, text=None):
@@ -476,14 +479,216 @@ class AutoSaveWorkflow:
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"
+ "_