File size: 12,984 Bytes
e488ce7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | """MAI video output: the file plus everything the next step wants beside it.
MAIVideoOut frames (+audio) -> mp4 via ComfyUI's own encoder, with
sidecars: <prefix>.holdmap.json (the clock, when wired),
<prefix>.meta.json (seeds, models, steps, wall time read out
of the graph that ran), and an OPTIONAL draft preview
decoded from the LATENT through the tiny VAE (taeh3 in
models/vae_approx), loaded only when the toggle is on and
released right after. Nothing is loaded or computed unless
asked: the default is exactly Create Video + Save Video.
MAILoadVideoPath a video by path (absolute, or relative to input/) -> VIDEO.
MAISelectEveryNth every n-th frame from an offset -> IMAGE (exact recovery's
uniform cousin), replacing the last VHS node in our graphs.
Why our own: the encode is core's already; what was missing is the record
beside the render (the scorer, the deck and the clock remap all want it) and a
preview that costs nothing unless you ask for it.
"""
import json
import os
import time
from fractions import Fraction
import torch
def _graph_summary(prompt):
"""What ran, read out of the executing graph: seeds, models, LoRAs, steps.
Best-effort; any shape of graph is fine, unknown nodes are skipped."""
out = {"seeds": [], "models": [], "loras": [], "text_encoders": [], "vaes": [],
"steps": [], "samplers": [], "schedulers": [], "inject": [], "hold_maps": 0}
if not isinstance(prompt, dict):
return out
for nid, node in prompt.items():
if not isinstance(node, dict):
continue
ct, ins = node.get("class_type", ""), node.get("inputs", {})
def lit(k):
v = ins.get(k)
return None if isinstance(v, list) else v
if "noise_seed" in ins and lit("noise_seed") is not None:
out["seeds"].append(lit("noise_seed"))
if "seed" in ins and lit("seed") is not None:
out["seeds"].append(lit("seed"))
if ct == "UNETLoader":
out["models"].append(lit("unet_name"))
if ct in ("LoraLoaderModelOnly", "LoraLoader"):
out["loras"].append({"name": lit("lora_name"), "strength": lit("strength_model")})
if ct == "CLIPLoader":
out["text_encoders"].append(lit("clip_name"))
if ct == "VAELoader":
out["vaes"].append(lit("vae_name"))
if ct in ("BasicScheduler", "KSampler", "KSamplerAdvanced") and lit("steps") is not None:
out["steps"].append({"node": nid, "class": ct, "steps": lit("steps"),
"scheduler": lit("scheduler"), "denoise": lit("denoise")})
if ct == "KSamplerSelect":
out["samplers"].append(lit("sampler_name"))
if ct == "H3InjectSchedule":
out["inject"].append({"total_steps": lit("total_steps"), "inject": lit("inject"),
"preset": lit("preset"), "scheduler": lit("scheduler")})
if ct in ("H3JerkOracle", "H3ManualHoldMap", "H3ClockRemap", "H3DrawnPlan"):
out["hold_maps"] += 1
return out
def _unique(base, ext):
path, k = f"{base}{ext}", 1
while os.path.exists(path):
k += 1
path = f"{base}_{k:05d}{ext}"
return path
class MAIVideoOut:
DESCRIPTION = (
"Save Video with the record beside it. Frames (+ audio) are encoded with "
"ComfyUI's own encoder, same as Create Video + Save Video. Optional "
"extras, each off unless wired or toggled:\n"
"- hold_map: the clock that produced these frames, written as "
"<prefix>.holdmap.json (H3 Load Hold Map and the deck's staircase read it)\n"
"- meta: <prefix>.meta.json with seeds, models, LoRAs, steps and the "
"encode time, read out of the graph that ran (plus your notes)\n"
"- draft preview: decode the LATENT through the tiny VAE in "
"models/vae_approx (taeh3 for H3) into <prefix>_draft.mp4. The tiny "
"VAE is loaded when this runs and released after; with the toggle off "
"nothing is loaded. Measured: 8-42 ms per latent frame by canvas.")
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"images": ("IMAGE",),
"fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.001}),
"filename_prefix": ("STRING", {"default": "video/mai"}),
"codec": (["h264", "av1"], {"default": "h264"}),
"crf": ("INT", {"default": 18, "min": 0, "max": 51,
"tooltip": "constant quality; lower is better and bigger (18 is near-lossless for review)"}),
}, "optional": {
"audio": ("AUDIO",),
"hold_map": ("STRING", {"default": "", "forceInput": True,
"tooltip": "the clock that produced these frames (H3 Time Smear hold_map_used), saved beside the file"}),
"notes": ("STRING", {"default": "", "multiline": True,
"tooltip": "free text into the meta sidecar (what this arm is)"}),
"write_meta": ("BOOLEAN", {"default": True}),
"draft_preview": ("BOOLEAN", {"default": False,
"tooltip": "also decode `latent` through the tiny VAE into <prefix>_draft.mp4"}),
"latent": ("LATENT", {"tooltip": "the sampled latent, for the draft preview only"}),
"draft_vae_name": ("STRING", {"default": "taeh3",
"tooltip": "file stem under models/vae_approx used for the draft"}),
}, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("path", "meta_json")
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "image/minimax/video"
def save(self, images, fps, filename_prefix, codec="h264", crf=18, audio=None, hold_map="",
notes="", write_meta=True, draft_preview=False, latent=None, draft_vae_name="taeh3",
prompt=None, extra_pnginfo=None):
import folder_paths
from comfy_api.latest import InputImpl, Types
t0 = time.time()
w, h = int(images.shape[2]), int(images.shape[1])
full, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory(), w, h)
file = f"{filename}_{counter:05}_.mp4"
path = os.path.join(full, file)
video = InputImpl.VideoFromComponents(
Types.VideoComponents(images=images, audio=audio, frame_rate=Fraction(fps)))
metadata = {}
if extra_pnginfo:
metadata.update(extra_pnginfo)
if prompt is not None:
metadata["prompt"] = prompt
video.save_to(path, format=Types.VideoContainer("mp4"), codec=Types.VideoCodec(codec),
metadata=metadata or None, crf=crf)
results = [{"filename": file, "subfolder": subfolder, "type": "output"}]
base = os.path.join(full, f"{filename}_{counter:05}_")
sidecars = {}
if hold_map.strip():
hp = _unique(base[:-1] + ".holdmap", ".json") # <prefix>_00001.holdmap.json
with open(hp, "w") as f:
json.dump(json.loads(hold_map), f)
sidecars["holdmap"] = os.path.basename(hp)
draft = None
if draft_preview and latent is not None:
try:
td = time.time()
draft = self._draft(latent, draft_vae_name, fps, base[:-1] + "_draft", full, subfolder)
draft["seconds"] = round(time.time() - td, 2)
results.append(draft)
except Exception as e: # a preview must never cost the render
print(f"[MAINodes] MAIVideoOut draft preview skipped: {type(e).__name__}: {e}")
meta = {}
if write_meta: # written last, so it can name the draft
meta = {"file": file, "frames": int(images.shape[0]), "width": w, "height": h,
"fps": fps, "codec": codec, "crf": crf, "audio": audio is not None,
"notes": notes, "encode_s": round(time.time() - t0, 2),
"graph": _graph_summary(prompt), "sidecars": sidecars,
"draft": (draft["filename"] if draft else None),
"draft_s": (draft["seconds"] if draft else None),
"written": time.strftime("%Y-%m-%dT%H:%M:%S")}
mp = _unique(base[:-1] + ".meta", ".json")
with open(mp, "w") as f:
json.dump(meta, f, indent=1)
print(f"[MAINodes] MAIVideoOut -> {path}" + (f" (+{', '.join(sidecars.values())})" if sidecars else ""))
return {"ui": {"images": results, "animated": (True,)},
"result": (path, json.dumps(meta))}
@staticmethod
def _draft(latent, vae_stem, fps, draft_base, full, subfolder):
"""Decode through the tiny VAE in models/vae_approx, loaded for this call only."""
import folder_paths
import comfy.utils
import comfy.model_management as mm
from comfy.sd import VAE
from comfy_api.latest import InputImpl, Types
name = next((f for f in folder_paths.get_filename_list("vae_approx") if f.startswith(vae_stem)), None)
if name is None:
raise FileNotFoundError(f"no {vae_stem}* in models/vae_approx")
t0 = time.time()
tae = VAE(comfy.utils.load_torch_file(folder_paths.get_full_path("vae_approx", name)))
try:
if hasattr(tae.first_stage_model, "show_progress_bar"):
tae.first_stage_model.show_progress_bar = False
z = latent["samples"]
if hasattr(z, "is_nested") and z.is_nested:
z = z.tensors[0]
frames = tae.decode(z) # (T, H, W, 3) in [0, 1]
if frames.ndim == 5:
frames = frames.reshape(-1, *frames.shape[-3:])
frames = frames.clamp(0, 1).cpu()
finally:
del tae
mm.soft_empty_cache()
video = InputImpl.VideoFromComponents(
Types.VideoComponents(images=frames, audio=None, frame_rate=Fraction(fps)))
out = _unique(draft_base, ".mp4")
video.save_to(out, format=Types.VideoContainer("mp4"), codec=Types.VideoCodec("h264"), crf=23)
print(f"[MAINodes] MAIVideoOut draft: {frames.shape[0]} frames through {name} in {time.time() - t0:.1f} s -> {out}")
return {"filename": os.path.basename(out), "subfolder": subfolder, "type": "output"}
class MAILoadVideoPath:
DESCRIPTION = ("A video by path: absolute, or relative to the input directory. "
"Outputs ComfyUI's VIDEO type (feed Get Video Components for frames "
"and audio), plus the frame count and fps it reports.")
@classmethod
def INPUT_TYPES(cls):
return {"required": {"path": ("STRING", {"default": "clip.mp4"})}}
RETURN_TYPES = ("VIDEO", "INT", "FLOAT")
RETURN_NAMES = ("video", "frames", "fps")
FUNCTION = "load"
CATEGORY = "image/minimax/video"
@classmethod
def IS_CHANGED(cls, path):
p = cls._resolve(path)
return f"{p}:{os.path.getmtime(p) if os.path.exists(p) else 0}"
@staticmethod
def _resolve(path):
import folder_paths
return path if os.path.isabs(path) else os.path.join(folder_paths.get_input_directory(), path)
def load(self, path):
from comfy_api.latest import InputImpl
p = self._resolve(path)
if not os.path.exists(p):
raise FileNotFoundError(p)
v = InputImpl.VideoFromFile(p)
comp = v.get_components()
return (v, int(comp.images.shape[0]), float(comp.frame_rate))
class MAISelectEveryNth:
DESCRIPTION = ("Every n-th frame starting at an offset (the uniform recover). For a "
"smear with a hold map use H3 Exact Recover instead: it is exact.")
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"images": ("IMAGE",),
"every": ("INT", {"default": 4, "min": 1, "max": 256}),
"offset": ("INT", {"default": 0, "min": 0, "max": 100000}),
}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "select"
CATEGORY = "image/minimax/video"
def select(self, images, every, offset):
return (images[offset::every].cpu(),)
NODE_CLASS_MAPPINGS = {"MAIVideoOut": MAIVideoOut, "MAILoadVideoPath": MAILoadVideoPath,
"MAISelectEveryNth": MAISelectEveryNth}
NODE_DISPLAY_NAME_MAPPINGS = {"MAIVideoOut": "MAI Video Out (file + sidecars + draft preview)",
"MAILoadVideoPath": "MAI Load Video (path)",
"MAISelectEveryNth": "MAI Select Every Nth"}
|