Guest User

Untitled

a guest
Aug 9th, 2026
120
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 13.32 KB | None | 0 0
  1. """Save and load MiniMax H3 nested AV latents in ComfyUI.
  2.  
  3. MiniMax H3 stores its latent as::
  4.  
  5. {"samples": NestedTensor((video_latent, audio_latent))}
  6.  
  7. The built-in ComfyUI Save Latent node expects a single tensor, so this module
  8. stores the two H3 tensors under separate safetensors keys and reconstructs the
  9. NestedTensor when loading.
  10.  
  11. File format (safetensors container, custom .h3latent extension):
  12.  
  13. h3_video [B, 24, T_video, H, W]
  14. h3_audio [B, 32, 2, T_audio]
  15. h3_format_version scalar/int marker
  16. latent_noise_mask optional top-level LATENT noise_mask
  17.  
  18. Safetensors preserves the tensor dtype exactly. Tensors are copied to CPU and
  19. made contiguous before saving.
  20. """
  21.  
  22. from __future__ import annotations
  23.  
  24. import hashlib
  25. import json
  26. import os
  27. from typing import Any, Dict, Optional, Tuple
  28.  
  29. import torch
  30. import safetensors.torch
  31.  
  32. import comfy.nested_tensor
  33. import comfy.utils
  34. import folder_paths
  35.  
  36.  
  37. FORMAT_NAME = "comfyui_minimax_h3_nested_latent"
  38. FORMAT_VERSION = 1
  39. FILE_EXTENSION = ".h3latent"
  40.  
  41.  
  42. def _unwrap_h3_samples(samples: Dict[str, Any]) -> Tuple[torch.Tensor, torch.Tensor]:
  43. """Extract and validate the video/audio tensors from a ComfyUI LATENT."""
  44. if not isinstance(samples, dict):
  45. raise TypeError("samples must be a ComfyUI LATENT dictionary")
  46.  
  47. nested = samples.get("samples")
  48. if nested is None:
  49. raise ValueError("LATENT has no 'samples' entry")
  50.  
  51. if isinstance(nested, comfy.nested_tensor.NestedTensor):
  52. tensors = list(nested.unbind())
  53. elif isinstance(nested, (tuple, list)):
  54. tensors = list(nested)
  55. else:
  56. raise TypeError(
  57. "Expected MiniMax H3 NestedTensor(video, audio), got "
  58. f"{type(nested).__name__}"
  59. )
  60.  
  61. if len(tensors) != 2:
  62. raise ValueError(
  63. "MiniMax H3 LATENT must contain exactly two tensors "
  64. f"(video, audio); got {len(tensors)}"
  65. )
  66.  
  67. video, audio = tensors
  68. if not torch.is_tensor(video) or not torch.is_tensor(audio):
  69. raise TypeError("Both H3 nested objects must be torch tensors")
  70.  
  71. if video.ndim != 5 or video.shape[1] != 24:
  72. raise ValueError(
  73. "Expected H3 video latent [B, 24, T, H, W], got "
  74. f"shape={tuple(video.shape)}"
  75. )
  76.  
  77. if audio.ndim != 4 or audio.shape[1] != 32 or audio.shape[2] != 2:
  78. raise ValueError(
  79. "Expected H3 audio latent [B, 32, 2, T], got "
  80. f"shape={tuple(audio.shape)}"
  81. )
  82.  
  83. if video.shape[0] != audio.shape[0]:
  84. raise ValueError(
  85. "H3 video/audio batch sizes differ: "
  86. f"video={video.shape[0]}, audio={audio.shape[0]}"
  87. )
  88.  
  89. return video, audio
  90.  
  91.  
  92. def _tensor_for_save(tensor: torch.Tensor) -> torch.Tensor:
  93. """Safetensors requires dense, contiguous tensors."""
  94. if tensor.layout != torch.strided:
  95. tensor = tensor.to_dense()
  96. return tensor.detach().to(device="cpu").contiguous()
  97.  
  98.  
  99. def _json_metadata(samples: Dict[str, Any]) -> str:
  100. """Preserve small, JSON-safe LATENT fields other than tensor payloads."""
  101. metadata: Dict[str, Any] = {}
  102. for key, value in samples.items():
  103. if key in {"samples", "noise_mask"}:
  104. continue
  105. try:
  106. json.dumps(value)
  107. except (TypeError, ValueError):
  108. continue
  109. metadata[key] = value
  110. return json.dumps(metadata, ensure_ascii=False, separators=(",", ":"))
  111.  
  112.  
  113. def _safe_join(root: str, relative_path: str) -> str:
  114. """Resolve a path below root and reject directory traversal."""
  115. if not isinstance(relative_path, str) or not relative_path.strip():
  116. raise ValueError("latent file path is empty")
  117.  
  118. relative_path = relative_path.strip().replace("\\", os.sep).replace("/", os.sep)
  119. if os.path.isabs(relative_path):
  120. raise ValueError("Use a path relative to ComfyUI input/output, not an absolute path")
  121.  
  122. root_abs = os.path.abspath(root)
  123. full = os.path.abspath(os.path.join(root_abs, os.path.normpath(relative_path)))
  124. try:
  125. inside = os.path.commonpath((root_abs, full)) == root_abs
  126. except ValueError:
  127. inside = False
  128. if not inside:
  129. raise ValueError("latent file path escapes the selected ComfyUI directory")
  130. return full
  131.  
  132.  
  133. def _parse_file_reference(file_ref: str, location: str) -> Tuple[str, str, str]:
  134. """Return (absolute path, effective location, relative path).
  135.  
  136. Save returns references such as ``output:h3_latents/foo.h3latent``.
  137. The loader also accepts a plain relative path plus the location widget.
  138. """
  139. raw = (file_ref or "").strip()
  140. effective = location
  141.  
  142. for prefix in ("output:", "input:"):
  143. if raw.lower().startswith(prefix):
  144. effective = prefix[:-1]
  145. raw = raw[len(prefix):]
  146. break
  147.  
  148. if effective == "auto":
  149. effective = "output"
  150.  
  151. if effective == "output":
  152. root = folder_paths.get_output_directory()
  153. elif effective == "input":
  154. root = folder_paths.get_input_directory()
  155. else:
  156. raise ValueError(f"Unsupported location: {effective}")
  157.  
  158. full = _safe_join(root, raw)
  159. return full, effective, raw.replace(os.sep, "/")
  160.  
  161.  
  162. def _human_bytes(value: int) -> str:
  163. size = float(value)
  164. for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
  165. if size < 1024.0 or unit == "TiB":
  166. return f"{size:.2f} {unit}"
  167. size /= 1024.0
  168. return f"{value} B"
  169.  
  170.  
  171. class SaveMiniMaxH3NestedLatent:
  172. """Save both tensors from an H3 NestedTensor into one safetensors file."""
  173.  
  174. def __init__(self):
  175. self.output_dir = folder_paths.get_output_directory()
  176.  
  177. @classmethod
  178. def INPUT_TYPES(cls):
  179. return {
  180. "required": {
  181. "samples": ("LATENT",),
  182. "filename_prefix": (
  183. "STRING",
  184. {"default": "h3_latents/ComfyUI"},
  185. ),
  186. },
  187. "hidden": {
  188. "prompt": "PROMPT",
  189. "extra_pnginfo": "EXTRA_PNGINFO",
  190. },
  191. }
  192.  
  193. RETURN_TYPES = ("LATENT", "STRING", "STRING")
  194. RETURN_NAMES = ("samples", "saved_file", "info")
  195. FUNCTION = "save"
  196. OUTPUT_NODE = True
  197. CATEGORY = "MiniMax H3/Experimental"
  198. DESCRIPTION = (
  199. "Saves the H3 NestedTensor video and audio latents losslessly into one "
  200. ".h3latent safetensors file."
  201. )
  202.  
  203. def save(
  204. self,
  205. samples,
  206. filename_prefix="h3_latents/ComfyUI",
  207. prompt=None,
  208. extra_pnginfo=None,
  209. ):
  210. video, audio = _unwrap_h3_samples(samples)
  211.  
  212. full_output_folder, filename, counter, subfolder, _ = (
  213. folder_paths.get_save_image_path(filename_prefix, self.output_dir)
  214. )
  215. os.makedirs(full_output_folder, exist_ok=True)
  216.  
  217. file_name = f"{filename}_{counter:05}_{FILE_EXTENSION}"
  218. absolute_path = os.path.join(full_output_folder, file_name)
  219.  
  220. tensors: Dict[str, torch.Tensor] = {
  221. "h3_video": _tensor_for_save(video),
  222. "h3_audio": _tensor_for_save(audio),
  223. "h3_format_version": torch.tensor([FORMAT_VERSION], dtype=torch.int32),
  224. }
  225.  
  226. noise_mask = samples.get("noise_mask")
  227. if torch.is_tensor(noise_mask):
  228. tensors["latent_noise_mask"] = _tensor_for_save(noise_mask)
  229.  
  230. metadata: Dict[str, str] = {
  231. "format": FORMAT_NAME,
  232. "version": str(FORMAT_VERSION),
  233. "video_shape": json.dumps(list(video.shape)),
  234. "audio_shape": json.dumps(list(audio.shape)),
  235. "video_dtype": str(video.dtype),
  236. "audio_dtype": str(audio.dtype),
  237. "latent_metadata": _json_metadata(samples),
  238. }
  239.  
  240. if prompt is not None:
  241. metadata["prompt"] = json.dumps(prompt, ensure_ascii=False, default=str)
  242. if extra_pnginfo is not None:
  243. metadata["extra_pnginfo"] = json.dumps(
  244. extra_pnginfo,
  245. ensure_ascii=False,
  246. default=str,
  247. )
  248.  
  249. # ComfyUI's helper writes a safetensors container regardless of our
  250. # custom extension. Every tensor has already been made CPU-contiguous.
  251. comfy.utils.save_torch_file(tensors, absolute_path, metadata=metadata)
  252.  
  253. relative = os.path.relpath(absolute_path, self.output_dir).replace(os.sep, "/")
  254. file_ref = f"output:{relative}"
  255. file_size = os.path.getsize(absolute_path)
  256. info = (
  257. f"Saved {file_ref}; video={tuple(video.shape)} {video.dtype}; "
  258. f"audio={tuple(audio.shape)} {audio.dtype}; size={_human_bytes(file_size)}"
  259. )
  260.  
  261. return {
  262. "ui": {"text": [info]},
  263. "result": (samples, file_ref, info),
  264. }
  265.  
  266.  
  267. class LoadMiniMaxH3NestedLatent:
  268. """Load an H3 AV latent and reconstruct NestedTensor((video, audio))."""
  269.  
  270. @classmethod
  271. def INPUT_TYPES(cls):
  272. return {
  273. "required": {
  274. "file": (
  275. "STRING",
  276. {"default": "output:h3_latents/ComfyUI_00001_.h3latent"},
  277. ),
  278. "location": (
  279. ["auto", "output", "input"],
  280. {"default": "auto"},
  281. ),
  282. }
  283. }
  284.  
  285. RETURN_TYPES = ("LATENT", "STRING")
  286. RETURN_NAMES = ("samples", "info")
  287. FUNCTION = "load"
  288. CATEGORY = "MiniMax H3/Experimental"
  289. DESCRIPTION = (
  290. "Loads a .h3latent file and rebuilds the MiniMax H3 "
  291. "NestedTensor(video, audio) LATENT."
  292. )
  293.  
  294. def load(self, file, location="auto"):
  295. absolute_path, effective_location, relative = _parse_file_reference(
  296. file,
  297. location,
  298. )
  299. if not os.path.isfile(absolute_path):
  300. raise FileNotFoundError(
  301. f"H3 latent file does not exist: {effective_location}:{relative}"
  302. )
  303.  
  304. tensors = safetensors.torch.load_file(absolute_path, device="cpu")
  305.  
  306. required = {"h3_video", "h3_audio", "h3_format_version"}
  307. missing = sorted(required.difference(tensors))
  308. if missing:
  309. raise ValueError(
  310. "Not a supported H3 nested latent file; missing keys: "
  311. + ", ".join(missing)
  312. )
  313.  
  314. version_tensor = tensors["h3_format_version"]
  315. version = int(version_tensor.reshape(-1)[0].item())
  316. if version != FORMAT_VERSION:
  317. raise ValueError(
  318. f"Unsupported H3 latent format version {version}; "
  319. f"this node supports {FORMAT_VERSION}"
  320. )
  321.  
  322. video = tensors["h3_video"]
  323. audio = tensors["h3_audio"]
  324.  
  325. # Validate using the same rules as saving without requiring a temporary
  326. # NestedTensor on GPU.
  327. reconstructed = {
  328. "samples": comfy.nested_tensor.NestedTensor((video, audio))
  329. }
  330. _unwrap_h3_samples(reconstructed)
  331.  
  332. if "latent_noise_mask" in tensors:
  333. reconstructed["noise_mask"] = tensors["latent_noise_mask"]
  334.  
  335. # Restore small JSON-safe top-level LATENT fields when present.
  336. try:
  337. with safetensors.safe_open(absolute_path, framework="pt", device="cpu") as handle:
  338. file_metadata = handle.metadata() or {}
  339. latent_metadata = json.loads(file_metadata.get("latent_metadata", "{}"))
  340. if isinstance(latent_metadata, dict):
  341. reconstructed.update(latent_metadata)
  342. except (OSError, ValueError, TypeError, json.JSONDecodeError):
  343. # The tensors are the load-bearing data; metadata failure should not
  344. # invalidate an otherwise usable latent.
  345. pass
  346.  
  347. file_size = os.path.getsize(absolute_path)
  348. finite_video = bool(torch.isfinite(video).all().item())
  349. finite_audio = bool(torch.isfinite(audio).all().item())
  350. info = (
  351. f"Loaded {effective_location}:{relative}; "
  352. f"video={tuple(video.shape)} {video.dtype}; "
  353. f"audio={tuple(audio.shape)} {audio.dtype}; "
  354. f"finite(video/audio)={finite_video}/{finite_audio}; "
  355. f"size={_human_bytes(file_size)}"
  356. )
  357. return reconstructed, info
  358.  
  359. @classmethod
  360. def IS_CHANGED(cls, file, location="auto"):
  361. try:
  362. absolute_path, _, _ = _parse_file_reference(file, location)
  363. digest = hashlib.sha256()
  364. with open(absolute_path, "rb") as handle:
  365. for chunk in iter(lambda: handle.read(1024 * 1024), b""):
  366. digest.update(chunk)
  367. return digest.hexdigest()
  368. except (OSError, ValueError):
  369. return float("nan")
  370.  
  371. @classmethod
  372. def VALIDATE_INPUTS(cls, file, location="auto"):
  373. try:
  374. absolute_path, effective_location, relative = _parse_file_reference(
  375. file,
  376. location,
  377. )
  378. except ValueError as exc:
  379. return str(exc)
  380. if not os.path.isfile(absolute_path):
  381. return f"Invalid H3 latent file: {effective_location}:{relative}"
  382. if not relative.lower().endswith(FILE_EXTENSION):
  383. return f"Expected a {FILE_EXTENSION} file"
  384. return True
  385.  
  386.  
Add Comment
Please, Sign In to add comment