File size: 9,225 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 | """Model profiles: the per-model clock facts the de-rope pipeline needs, so one
hold map (integer hold per world frame) can be retimed for any model.
A profile is five numbers and a flag:
block frames per latent time block. Holds of 2+ are quantised to a
multiple of this, because a held frame only owns its own block
when its hold fills whole blocks (LTX-2.5: 8; measured, d=8
barely moves and d=16 does).
hold_scale how many world frames one oracle hold unit buys on this model.
The oracle emits holds of 1-4 in H3 terms (scale 1). LTX-2.5
needed x4 (hold 4 -> 16x) to move at all: MEASURED, not derived.
legal (step, offset): legal pixel lengths are step*k + offset
(H3 17k+5, LTX-2.5 8k+1, Wan 4k+1).
fps the model's native frame rate (what a second of holds costs).
cap_seconds longest single pass, or None. LTX-2.5: 20 s, from its absolute-
seconds positional cap. H3's wall is VRAM and moves with the card.
measured True only when hold_scale and block come from a ladder on this
model. A custom or unmeasured profile is stamped as such in every
report: a confident default here is how LTX d=8 fooled us for a day.
latent how that model's VIDEO LATENT maps onto pixel frames, so the jerk
oracle can read the model's own latent: channels, and the token
clock as (first, block): token 0 owns `first` frames, every later
token owns `block` frames (LTX-2.5: 1 + 8k; Wan: 1 + 4k). H3 is
the exception with its (1,4,4,4,4)-per-17 grid and five-phase
normalisation, kept in motion.py bit-identical; `latent: None`
here means "use the H3 code path".
Holds of 1 are never scaled: a hold of 1 is plain video on any model.
Presets live in PRESETS. Users extend them without touching code by dropping a
JSON object {id: profile} at <ComfyUI user dir>/mainodes_models.json (or the
path in $MAINODES_MODELS_JSON); a row there overrides a preset of the same id.
Pure python, no torch, no comfy: unit-testable.
"""
import json
import os
PRESETS = {
"minimax-h3": dict(
name="MiniMax-H3", block=1, hold_scale=1, legal=(17, 5), fps=24,
cap_seconds=None, measured=True, latent=None,
note="native: the oracle's holds are in H3 frames already; smear pads to 17k+5"),
"ltx-2.5": dict(
name="LTX-2.5", block=8, hold_scale=4, legal=(8, 1), fps=48,
cap_seconds=20.0, measured=True, latent=dict(channels=128, first=1, block=8),
note="time compressed x8; hot holds must fill whole 8-frame blocks. x4 measured 2026-08-21 "
"(d=8 uniform: staircase 0.94-0.90 across the denoise range; d=16: 0.90-0.28). "
"fps is the frame_rate the LTXV conditioning is told (48 in the deck's graphs); the 20 s "
"positional cap is in seconds of THAT clock, so 913 dilated frames = 19.02 s. Longer "
"clips are windows, spliced"),
"wan-2.2 (unmeasured)": dict(
name="Wan 2.2", block=4, hold_scale=4, legal=(4, 1), fps=16,
cap_seconds=5.0, measured=False, latent=dict(channels=16, first=1, block=4),
note="placeholder from the model's published grid (4x time, 4k+1 frames, 16 fps, "
"trained at 81 frames = 5 s so that is the cap); hold_scale is a guess until a "
"ladder is run. Treat every number as unmeasured"),
}
FIELDS = ("name", "block", "hold_scale", "legal", "fps", "cap_seconds", "measured", "note", "latent")
def _user_registry_path():
p = os.environ.get("MAINODES_MODELS_JSON")
if p:
return p
try:
import folder_paths # only inside ComfyUI
return os.path.join(folder_paths.get_user_directory(), "mainodes_models.json")
except Exception:
return None
def normalize(pid, raw):
"""Fill defaults and coerce types for a profile row; never raises on a
missing optional field, always raises on a malformed required one."""
p = dict(raw)
out = dict(
id=pid,
name=str(p.get("name", pid)),
block=max(1, int(p["block"])),
hold_scale=max(1, int(p["hold_scale"])),
legal=(int(p["legal"][0]), int(p["legal"][1])),
fps=float(p.get("fps", 24)),
cap_seconds=(None if p.get("cap_seconds") in (None, 0, "") else float(p["cap_seconds"])),
measured=bool(p.get("measured", False)),
note=str(p.get("note", "")),
latent=(None if not p.get("latent") else dict(
channels=int(p["latent"].get("channels", 0)),
first=max(1, int(p["latent"].get("first", 1))),
block=max(1, int(p["latent"].get("block", 1))))),
)
if out["legal"][0] < 1:
raise ValueError(f"profile {pid}: legal step must be >= 1")
return out
def load_profiles(extra_path=None):
"""PRESETS merged with the user registry (user rows win). Never raises:
a broken registry prints and is skipped, so the pack still loads."""
profiles = {k: normalize(k, v) for k, v in PRESETS.items()}
path = extra_path or _user_registry_path()
if path and os.path.exists(path):
try:
user = json.load(open(path))
for k, v in user.items():
profiles[str(k)] = normalize(str(k), v)
except Exception as e: # a typo must not take the pack down
print(f"[MAINodes] model registry {path} not loaded: {type(e).__name__}: {e}")
return profiles
def legal_ceil(n, legal, min_k=0):
"""Smallest step*k + offset >= n."""
step, off = legal
k = max(min_k, -(-(n - off) // step))
return step * k + off
def remap_holds(holds, profile):
"""Oracle holds (H3 units) -> holds on the target model's clock.
1 stays 1. h >= 2 becomes h * hold_scale, quantised to a whole number of
blocks (never below one block). H3's profile (block 1, scale 1) is the
identity, so existing graphs are unchanged by construction."""
b, s = profile["block"], profile["hold_scale"]
out = []
for h in holds:
h = int(h)
if h <= 1:
out.append(1)
else:
out.append(max(b, int(round(h * s / b)) * b))
return out
def pad_to_legal(holds, legal):
"""Extend the final hold so the total sits on the legal grid (what
H3TimeSmear does with 17k+5). Returns (holds, total, pad)."""
holds = list(holds)
total = legal_ceil(sum(holds), legal)
pad = total - sum(holds)
holds[-1] += pad
return holds, total, pad
def windows_needed(total_frames, profile):
"""How many passes a dilated length needs under the profile's cap."""
if not profile.get("cap_seconds"):
return 1
cap = int(profile["cap_seconds"] * profile["fps"])
return max(1, -(-total_frames // cap))
def report(holds_in, holds_out, total, pad, profile):
held = sum(1 for h in holds_in if h > 1)
n = len(holds_in)
lines = [
f"profile {profile['id']} ({profile['name']}): block {profile['block']}, "
f"hold_scale x{profile['hold_scale']}, legal {profile['legal'][0]}k+{profile['legal'][1]}, "
f"{profile['fps']:g} fps" + (f", cap {profile['cap_seconds']:g} s/pass" if profile.get('cap_seconds') else ""),
f"world {n} frames, {held} held -> {total} dilated frames ({total / profile['fps']:.2f} s at "
f"{profile['fps']:g} fps), tail pad {pad}",
f"hold histogram in -> out: {_hist(holds_in)} -> {_hist(holds_out)}",
]
w = windows_needed(total, profile)
if w > 1:
lines.append(f"EXCEEDS the per-pass cap: {w} windows needed (splice them)")
if not profile.get("measured"):
lines.append("UNMEASURED profile: block / hold_scale are not from a ladder on this model; "
"treat the retime as an experiment, not a recipe")
return "\n".join(lines)
def _hist(holds):
from collections import Counter
c = Counter(int(h) for h in holds)
return "{" + ", ".join(f"{k}:{v}" for k, v in sorted(c.items())) + "}"
# ------------------------------------------------------------ latent clock
# A generic (first, block) token clock: token 0 owns `first` pixel frames, each
# later token owns `block`. These are the three functions the oracle's planner
# needs; motion.py keeps H3's own (1,4,4,4,4)-per-17 versions and uses these
# only when a profile carries a `latent` entry.
class LatentClock:
def __init__(self, profile):
lat = profile["latent"]
self.first, self.block = lat["first"], lat["block"]
self.channels = lat.get("channels", 0)
self.legal = profile["legal"]
self.id = profile["id"]
def tok_start_frame(self, t):
return 0 if t == 0 else self.first + (t - 1) * self.block
def frame_token(self, f, t_lat):
if f < self.first:
return 0
return min(1 + (f - self.first) // self.block, t_lat - 1)
def token_count(self, frames):
n = legal_ceil(frames, self.legal)
return 1 + max(0, -(-(n - self.first) // self.block))
def legal_ceil(self, n):
return legal_ceil(n, self.legal)
|