File size: 9,722 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 | """MAI Video Compare: a browser-native synchronized viewer for 2-6 renders.
The node does NO video processing. Each wired VIDEO is written once to the temp
directory as a small h264 preview (CPU encode, no VAE, no tensors kept), and the
widget in web/video_compare.js plays them with the flipbook's player: flip /
wipe / side-by-side for a pair, a grid for more, one set of live <video>
elements, audio on the hovered or locked source, synchronized scrub, frame
step, A/B flicker. "Winner" is a widget the UI sets; the node passes that
source through on the NEXT queue (Seed Hunter semantics: choose, then finalize
in a second execution, never pause a graph for a human).
"""
import json
import os
import time
from fractions import Fraction
def _spans_from_hold_map(hold_map):
"""Regenerated-window frame spans (inclusive, world frames) for a hold map.
A hold above 1 marks a frame H3 Time Smear dilated, which is exactly the
material a window regenerates, so the spans are the planner's bursts. That
arithmetic is imported, never restated: one definition of a burst.
"""
from .window_expand import _bursts
holds = [int(h) for h in (json.loads(hold_map).get("holds") or [])]
return [[int(a), int(b)] for a, b in _bursts(holds)]
def _preview_extras(hold_map="", curves=""):
"""The optional manifest keys the widget draws: `spans` (the regenerated
band, and what the enter/exit blips fire on) and `curves` (per-frame lanes
under the playhead). Absent inputs add no keys at all, so an old two-video
call still emits the manifest it always did, byte for byte. Both are
best-effort: a malformed string costs a line on stdout, never the render.
"""
out = {}
if str(hold_map).strip():
try:
spans = _spans_from_hold_map(hold_map)
if spans:
out["spans"] = spans
except Exception as e:
print(f"[MAIVideoCompare] hold_map ignored: {type(e).__name__}: {e}")
if str(curves).strip():
try:
parsed = json.loads(curves)
lanes = {str(k): [float(x) for x in v] for k, v in parsed.items()
if isinstance(v, (list, tuple)) and len(v) > 1}
if lanes:
out["curves"] = lanes
except Exception as e:
print(f"[MAIVideoCompare] curves ignored: {type(e).__name__}: {e}")
return out
class MAIVideoCompare:
DESCRIPTION = (
"Compare 2-6 renders in the browser: flip / wipe / side-by-side for a "
"pair, a synchronized grid for more. Hover a source to hear it, click "
"to lock its audio, scrub or step all together, A/B flicker on a key. "
"Costs no VRAM: previews are written once as small h264 files and the "
"browser decodes them. Mark a winner in the widget; the next queue "
"passes that source through `winner_video` (Seed Hunter: pick, then "
"finalize in a second execution).")
@classmethod
def INPUT_TYPES(cls):
req = {"winner": ("INT", {"default": 1, "min": 1, "max": 6,
"tooltip": "set by the viewer's star; which source winner_video passes through"}),
"preview_crf": ("INT", {"default": 23, "min": 10, "max": 40})}
opt = {}
for i in range(1, 7):
opt[f"video_{i}"] = ("VIDEO",)
for i in range(1, 7):
opt[f"label_{i}"] = ("STRING", {"default": ""})
opt.update(cls._viewer_inputs())
return {"required": req, "optional": opt, "hidden": {"unique_id": "UNIQUE_ID"}}
@staticmethod
def _viewer_inputs():
"""Viewer-only extras, and they go LAST wherever they are used: an
input inserted anywhere else shifts every older workflow's widget
order. MAISeedHunter re-appends them after its seeds for the same
reason.
"""
return {
"hold_map": ("STRING", {"default": "", "forceInput": True,
"tooltip": "H3 Time Smear's hold_map_used: the viewer draws the regenerated window as a band and fires enter/exit blips on it"}),
"curves": ("STRING", {"default": "", "forceInput": True,
"tooltip": "JSON {name: [per-frame floats]}; drawn as lanes under the playhead"}),
}
RETURN_TYPES = ("VIDEO", "INT", "STRING")
RETURN_NAMES = ("winner_video", "winner_index", "manifest")
FUNCTION = "compare"
OUTPUT_NODE = True
CATEGORY = "image/minimax/video"
def compare(self, winner=1, preview_crf=23, unique_id=None, hold_map="",
curves="", **kw):
import folder_paths
from comfy_api.latest import Types
tmp = folder_paths.get_temp_directory()
sub = "mai_compare"
os.makedirs(os.path.join(tmp, sub), exist_ok=True)
stamp = time.strftime("%H%M%S")
items, videos = [], {}
for i in range(1, 7):
v = kw.get(f"video_{i}")
if v is None:
continue
w, h = v.get_dimensions()
fn = f"cmp_{unique_id or 'n'}_{stamp}_{i}.mp4"
path = os.path.join(tmp, sub, fn)
v.save_to(path, format=Types.VideoContainer("mp4"), codec=Types.VideoCodec("h264"), crf=preview_crf)
# comfy_api writes ~2 keyframes per clip; a sync seek then decodes
# up to half the GOP before showing a frame, which the viewer does
# constantly. Re-encode previews seek-friendly: keyframe every 12,
# faststart, 48 kHz audio. Best-effort: on any failure the
# save_to file stands.
try:
import subprocess
path2 = path[:-4] + "_sk.mp4"
r = subprocess.run(["ffmpeg", "-y", "-v", "error", "-threads", "4",
"-i", path, "-c:v", "libx264", "-crf", str(preview_crf),
"-g", "12", "-preset", "veryfast", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-ar", "48000",
"-movflags", "+faststart", path2],
capture_output=True, timeout=120)
if r.returncode == 0 and os.path.getsize(path2) > 0:
os.replace(path2, path)
else:
print(f"[MAIVideoCompare] preview re-encode skipped: {r.stderr.decode()[:160]}")
except Exception as e: # noqa: BLE001
print(f"[MAIVideoCompare] preview re-encode skipped: {type(e).__name__}: {e}")
comp = v.get_components()
items.append({"index": i, "label": kw.get(f"label_{i}") or f"source {i}",
"filename": fn, "subfolder": sub, "type": "temp",
"frames": int(comp.images.shape[0]), "fps": float(comp.frame_rate),
"width": w, "height": h, "audio": comp.audio is not None})
videos[i] = v
if not videos:
raise ValueError("wire at least two videos to compare")
win = winner if winner in videos else min(videos)
manifest = {"items": items, "winner": win}
manifest.update(_preview_extras(hold_map, curves))
return {"ui": {"mai_compare": [manifest]},
"result": (videos[win], int(win), json.dumps(manifest))}
class MAISeedHunter(MAIVideoCompare):
"""Video Compare that knows about seeds: cheap candidates in (with the seed
each ran on), the starred one's seed out, for the finalize pass's noise node.
Pin / reject are view state in the widget; the decision that matters is the
star, and it only takes effect on the next queue."""
DESCRIPTION = (
"Seed hunting: wire 2-6 cheap candidates and the seed each ran on, watch "
"them synchronized (hover to hear, flicker, frame-lock), star the keeper. "
"The next queue passes the starred candidate's seed out of `winner_seed` "
"(wire it into the finalize pass's RandomNoise) and its video out of "
"`winner_video`. Pick, then finalize: two executions, never a graph "
"waiting on a human.")
@classmethod
def INPUT_TYPES(cls):
t = MAIVideoCompare.INPUT_TYPES()
tail = {k: t["optional"].pop(k) for k in MAIVideoCompare._viewer_inputs()}
for i in range(1, 7):
t["optional"][f"seed_{i}"] = ("INT", {"default": 0, "min": 0, "max": 2**53 - 1,
"tooltip": f"the seed candidate {i} ran on"})
t["optional"].update(tail) # the viewer extras stay last here too
return t
RETURN_TYPES = ("VIDEO", "INT", "INT", "STRING")
RETURN_NAMES = ("winner_video", "winner_seed", "winner_index", "manifest")
def compare(self, winner=1, preview_crf=23, unique_id=None, **kw):
seeds = {i: kw.pop(f"seed_{i}", 0) for i in range(1, 7)}
for i in range(1, 7):
if kw.get(f"video_{i}") is not None and not kw.get(f"label_{i}"):
kw[f"label_{i}"] = f"seed {seeds[i]}"
res = MAIVideoCompare.compare(self, winner=winner, preview_crf=preview_crf, unique_id=unique_id, **kw)
video, win, manifest = res["result"]
m = json.loads(manifest)
for it in m["items"]:
it["seed"] = seeds.get(it["index"], 0)
res["ui"]["mai_compare"] = [m]
res["result"] = (video, int(seeds.get(win, 0)), win, json.dumps(m))
return res
NODE_CLASS_MAPPINGS = {"MAIVideoCompare": MAIVideoCompare, "MAISeedHunter": MAISeedHunter}
NODE_DISPLAY_NAME_MAPPINGS = {"MAIVideoCompare": "MAI Video Compare (2-6, synchronized)",
"MAISeedHunter": "MAI Seed Hunter (compare + winner seed)"}
|