File size: 10,478 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 | """De-rope for any model: retime one hold map onto a target model's clock.
H3ClockRemap hold_map (from H3 Jerk Oracle / H3 Manual Hold Map / H3 Drawn
Plan) + a model profile -> hold_map on that model's clock, with
the target grid carried INSIDE the map so H3 Time Smear pads to
the right legal length and H3 Exact Recover inverts it exactly.
H3SaveHoldMap write the hold map beside the render (<prefix>.holdmap.json) so
the staircase ruler and the next remap can read what was held.
The smear / recover / audio nodes are pixel-level and model-agnostic already;
this module is the grid law lifted out of them into data (model_profiles.py).
"""
import json
import os
try:
from .model_profiles import (load_profiles, normalize, remap_holds, pad_to_legal,
report as _report)
except ImportError: # tests import this top-level
from model_profiles import (load_profiles, normalize, remap_holds, pad_to_legal,
report as _report)
CUSTOM = "custom (use the fields below)"
class H3ClockRemap:
DESCRIPTION = (
"EXPERIMENTAL (alpha), new 2026-08-21. Retimes a hold map onto another "
"model's clock.\n\n"
"Wire any hold map (H3 Jerk Oracle, H3 Manual Hold Map, H3 Drawn Plan) "
"and pick the model that will regenerate the smeared frames. Holds of "
"2+ are scaled by the profile's hold_scale and quantised to whole "
"latent time blocks; holds of 1 stay 1 (plain video on any model). The "
"output map carries the target's legal frame grid, so H3 Time Smear "
"pads to THAT grid (LTX-2.5: 8k+1) instead of H3's 17k+5, and H3 "
"Exact Recover / H3 Audio Recover invert it unchanged.\n\n"
"'minimax-h3' is the identity. 'custom' exposes the fields for a model "
"that has no preset yet; a working custom row can be saved as a preset "
"by adding it to <user dir>/mainodes_models.json (see "
"DEROPE_ANY_MODEL.md). The report is the price tag and says when the "
"profile is unmeasured or the retime needs more than one pass.")
@classmethod
def INPUT_TYPES(cls):
profiles = load_profiles()
return {"required": {
"hold_map": ("STRING", {"default": "", "forceInput": True,
"tooltip": "per-frame integer holds, oracle format"}),
"model_profile": (list(profiles) + [CUSTOM], {"default": "ltx-2.5"}),
}, "optional": {
"custom_block": ("INT", {"default": 8, "min": 1, "max": 64,
"tooltip": "custom: frames per latent time block"}),
"custom_hold_scale": ("INT", {"default": 4, "min": 1, "max": 32,
"tooltip": "custom: world frames one oracle hold unit buys"}),
"custom_legal_step": ("INT", {"default": 8, "min": 1, "max": 64,
"tooltip": "custom: legal lengths are step*k + offset"}),
"custom_legal_offset": ("INT", {"default": 1, "min": 0, "max": 64}),
"custom_fps": ("FLOAT", {"default": 25.0, "min": 1.0, "max": 120.0, "step": 0.001}),
"custom_cap_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 600.0, "step": 0.1,
"tooltip": "custom: longest single pass in seconds; 0 = no cap"}),
}}
RETURN_TYPES = ("STRING", "INT", "STRING", "STRING")
RETURN_NAMES = ("hold_map", "length", "report", "profile_json")
FUNCTION = "remap"
CATEGORY = "image/minimax/motion"
def remap(self, hold_map, model_profile, custom_block=8, custom_hold_scale=4,
custom_legal_step=8, custom_legal_offset=1, custom_fps=25.0,
custom_cap_seconds=0.0):
m = json.loads(hold_map)
holds_in = [int(h) for h in m["holds"]]
if model_profile == CUSTOM:
prof = normalize("custom", dict(
name="custom", block=custom_block, hold_scale=custom_hold_scale,
legal=(custom_legal_step, custom_legal_offset), fps=custom_fps,
cap_seconds=custom_cap_seconds or None, measured=False,
note="typed into H3 Clock Remap"))
else:
profiles = load_profiles()
if model_profile not in profiles:
raise ValueError(f"unknown model profile {model_profile!r}; "
f"known: {sorted(profiles)}")
prof = profiles[model_profile]
holds_out = remap_holds(holds_in, prof)
# padding to the legal grid is H3 Time Smear's job (it reads the grid
# carried below); the map itself stays unpadded so minimax-h3 is the
# exact identity and a remapped map can be cropped before smearing.
_, total, pad = pad_to_legal(holds_out, prof["legal"])
rep = _report(holds_in, holds_out, total, pad, prof)
out = {"holds": holds_out, "world_len": m.get("world_len", len(holds_in)),
"profile": prof["id"], "legal": list(prof["legal"]), "fps": prof["fps"],
"source_holds": holds_in}
if m.get("window"): # a cropped clock keeps saying where it sits
out["window"] = m["window"]
print("[MAINodes] H3ClockRemap " + rep.replace("\n", " | "))
return (json.dumps(out), int(total), rep, json.dumps(prof))
class H3SaveHoldMap:
DESCRIPTION = (
"Writes a hold map beside the render as <output>/<filename_prefix>"
".holdmap.json. Wire H3 Time Smear's hold_map_used (what was actually "
"held, after padding) and the same filename_prefix as the SaveVideo of "
"that arm. The file is what the staircase ruler and a later H3 Clock "
"Remap read; without it a render's clock is lost the moment the graph "
"closes.")
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"hold_map": ("STRING", {"default": "", "forceInput": True}),
"filename_prefix": ("STRING", {"default": "video/derope"}),
}}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("path",)
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "image/minimax/motion"
def save(self, hold_map, filename_prefix):
import folder_paths
root = folder_paths.get_output_directory()
base = os.path.join(root, filename_prefix + ".holdmap")
os.makedirs(os.path.dirname(base), exist_ok=True)
path, k = base + ".json", 1
while os.path.exists(path):
k += 1
path = f"{base}_{k:05d}.json"
data = json.loads(hold_map)
with open(path, "w") as f:
json.dump(data, f)
print(f"[MAINodes] H3SaveHoldMap -> {path}")
return {"ui": {"text": [os.path.relpath(path, root)]}, "result": (path,)}
class H3LoadHoldMap:
DESCRIPTION = (
"Reads a hold-map sidecar written by H3 Save Hold Map, so a clock "
"decided in one graph (the H3 pass with its oracle) can drive another "
"(an LTX-2.5 or Wan pass) without both models in one graph. Give the "
"SaveVideo prefix of the arm (e.g. 'video/panrun_base'): the newest "
"<prefix>.holdmap*.json under the output directory is used. Absolute "
"paths and paths ending in .json are taken as they are. Outputs the map "
"(wire into H3 Clock Remap or straight into H3 Time Smear) and its "
"world length.")
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"path": ("STRING", {"default": "video/derope",
"tooltip": "SaveVideo prefix of the arm whose clock you want, or a .json path"}),
}, "optional": {
"start": ("INT", {"default": 0, "min": 0, "max": 100000,
"tooltip": "crop the clock to a window of source frames (for a model whose "
"per-pass cap the whole clip exceeds); feed the SAME window of "
"frames to H3 Time Smear (ImageFromBatch start/length)"}),
"length": ("INT", {"default": 0, "min": 0, "max": 100000,
"tooltip": "window length in source frames; 0 = to the end"}),
}}
RETURN_TYPES = ("STRING", "INT", "STRING")
RETURN_NAMES = ("hold_map", "world_len", "path")
FUNCTION = "load"
CATEGORY = "image/minimax/motion"
def load(self, path, start=0, length=0):
import glob
import folder_paths
root = folder_paths.get_output_directory()
if not path.endswith(".json"):
cands = sorted(glob.glob(os.path.join(root, path + ".holdmap*.json")), key=os.path.getmtime)
if not cands:
raise FileNotFoundError(f"no hold-map sidecar for prefix {path!r} under {root}")
path = cands[-1]
elif not os.path.isabs(path):
path = os.path.join(root, path)
data = json.load(open(path))
# only the clock travels: the target grid of the graph that wrote it must
# not leak into a different model's smear
clock = {"holds": [int(h) for h in data["holds"]],
"world_len": int(data.get("world_len", len(data["holds"])))}
if "source_holds" in data: # a remapped map: hand back the ORIGINAL clock
clock["holds"] = [int(h) for h in data["source_holds"]]
if start or length:
end = start + length if length else len(clock["holds"])
clock["holds"] = clock["holds"][start:end]
clock["world_len"] = len(clock["holds"])
clock["window"] = [int(start), int(end)]
if not clock["holds"]:
raise ValueError(f"window {start}:{end} is outside the {len(data['holds'])}-frame clock")
print(f"[MAINodes] H3LoadHoldMap <- {path} ({clock['world_len']} world frames"
+ (f", window {clock['window']}" if "window" in clock else "") + ")")
return (json.dumps(clock), clock["world_len"], path)
NODE_CLASS_MAPPINGS = {"H3ClockRemap": H3ClockRemap, "H3SaveHoldMap": H3SaveHoldMap,
"H3LoadHoldMap": H3LoadHoldMap}
NODE_DISPLAY_NAME_MAPPINGS = {"H3ClockRemap": "H3 Clock Remap (any model, presets)",
"H3SaveHoldMap": "H3 Save Hold Map (sidecar)",
"H3LoadHoldMap": "H3 Load Hold Map (sidecar)"}
|