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"}