File size: 27,522 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 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 | """Extension: make a long clip out of short generations (alpha, 2026-08-23).
The job: "my card only comfortably renders five seconds, I want forty."
Generate a segment, de-rope it, carry its last HANDLE frames (video and
the matching audio) into the next generation as opening context, generate
the next segment, de-rope it with the carried prefix protected, trim the
prefix and the grid surplus, append. Repeat.
Four nodes, no model patch, no new de-rope:
H3 Extension Plan the arithmetic, in integers: segment and handle
lengths on the 17k+5 grid and the 40 Hz audio clock,
requested vs resolved, the anchor modes, one report
H3 Tail Context the last HANDLE frames + sample-exact audio of a
finished segment, ready for MiniMaxH3AddGuide @ 0
H3 Protect Prefix hold_map[:HANDLE] = 1 so the de-rope sees the incoming
velocity but may not retime the anchor; on a non-final
segment also hold_map[-17:] = 1, because a burst that
runs into the cut has no "after" for the model to slow
into and comes back fast after recovery
H3 Prefix Freeze a time-varying V2V Init mask: the prefix keeps its init
in pass 2 instead of being re-textured
H3 Trim drop the hidden prefix and the surplus, audio
sample-exact, and advance the global timeline
H3 Seam Normalize the hidden prefix is calibration data: fit gains that
map it onto the source tail, apply them to the new
material (colour in linear light, audio rms + fade)
The default atom is 141/39: 141 frames = 235 ticks, handle 39 = 65 ticks,
new material 102 = 170 ticks, the handle starts at 102 = 6x17 in the
source and lands at 0 in the destination. Every append adds exactly 102
frames and 170 ticks, so nothing fractional accumulates. 22 is legal on
the frame grid but not on the audio clock (36.67 ticks); the plan says so
rather than letting the chain drift.
Measured basis (h3-extend-and-heal, 2026-08-14): a 39-frame guide-anchored
join was playback-clean as a hard cut, overlap MAE 2.18/255 vs 16.47
unanchored, one neutral cell at 0.4 MP, base 12-step. 5 and 22 are
unmeasured cells.
Two ways to anchor the handle, both full H3 VAE, never noised, never TAE:
MASKED (default when the core has #15375): the tail is written into the
target latent of pass 1, the rest of the latent is the last tail frame
repeated, and a time-varying per-token mask keeps the prefix; the audio
handle rides an audio-only MiniMaxH3AddGuide at frame 0. GUIDE (fallback):
the tail as an image+audio guide clip at frame 0. Measured 2026-08-23 on
one cell (pass 1, 141/39, 1 MP): masked join jerk 0.84x the clip's median
vs 5 to 12x for the guide, camera decelerates through zero instead of
reversing, prefix MAE 2.2 vs 3.0, and 22% less wall (no guide rows in
every block).
"""
from __future__ import annotations
import json
import logging
import math
from fractions import Fraction
import torch
from .capsule_types import (SCHEMA, Handle, Span, Timebase, align_down, align_up,
canonical_json, digest)
log = logging.getLogger("MAINodes.h3_extend")
TRANSITIONS = {
"seamless (39-frame handle)": 39,
"cheap seamless (5-frame handle, unmeasured)": 5,
"frame continuity (last frame only)": 1,
"scene cut (no handle, global refs only)": 0,
"custom (use handle_frames)": -1,
}
def _probe():
try:
from .h3_capabilities import probe_core
return probe_core()
except Exception: # noqa: BLE001
return {}
class H3ExtensionPlan:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"segment_frames": ("INT", {"default": 141, "min": 5, "max": 3600,
"tooltip": "frames to GENERATE per segment, handle included; rounds UP to 17k+5 like core. 141 = the atom"}),
"transition": (list(TRANSITIONS), {"default": "seamless (39-frame handle)"}),
"handle_frames": ("INT", {"default": 39, "min": 0, "max": 600,
"tooltip": "only read for transition = custom; clip guides round DOWN to 17k+5, below 5 becomes one frame"}),
"new_frames": ("INT", {"default": 0, "min": 0, "max": 3600,
"tooltip": "frames of NEW material wanted from this segment; 0 = everything after the handle (102 on the atom). More than fits raises the generation length"}),
"visual_anchor": (["auto", "guide", "per_token_mask", "none"], {"default": "auto"}),
"audio_anchor": (["auto", "guide", "regenerated", "none"], {"default": "auto"}),
"fps_num": ("INT", {"default": 24, "min": 1, "max": 240}),
"fps_den": ("INT", {"default": 1, "min": 1, "max": 1001}),
"previous_length": ("INT", {"default": 141, "min": 0, "max": 3600,
"tooltip": "frames in the FINISHED previous segment (after its own trim, or the first segment's full length); places the handle's source span"}),
}, "optional": {
"final_segment": ("BOOLEAN", {"default": False,
"tooltip": "off for every segment another one will continue: the end of this segment is not the end of the shot, so H3 Time Smear's expand_to_end (end-jump protection, which lifts the LAST frames to the highest hold) must be off there or a gesture ending the segment comes back accelerated after recovery. Wire `expand_to_end` to H3 Time Smear"}),
}}
RETURN_TYPES = ("STRING", "INT", "INT", "INT", "STRING", "BOOLEAN")
RETURN_NAMES = ("plan", "length", "handle_frames", "new_frames", "report", "expand_to_end")
FUNCTION = "plan"
CATEGORY = "MAINodes/alpha"
DESCRIPTION = (
"EXPERIMENTAL (alpha), new 2026-08-23. Integer arithmetic for one extension step: "
"how many frames to generate so that HANDLE context frames plus NEW frames fit the 17k+5 grid, "
"whether each length lands on the 40 Hz audio clock (frames divisible by 3), where the handle "
"comes from in the previous segment and where it lands, and which anchor mechanism the running "
"core supports. Feed `length` to the H3 conditioning node, `plan` to H3 Tail Context, "
"H3 Protect Prefix and H3 Trim.")
def plan(self, segment_frames, transition, handle_frames, new_frames, visual_anchor, audio_anchor,
fps_num, fps_den, previous_length, final_segment=False):
tb = Timebase(fps_num=int(fps_num), fps_den=int(fps_den))
caps = _probe()
requested = {"segment_frames": int(segment_frames), "transition": transition,
"handle_frames": int(handle_frames), "new_frames": int(new_frames),
"visual_anchor": visual_anchor, "audio_anchor": audio_anchor}
reasons = {}
notes = []
# handle
h_req = TRANSITIONS[transition]
if h_req < 0:
h_req = int(handle_frames)
handle = align_down(h_req) if h_req >= 5 else h_req # 1 and 0 pass through
if handle != h_req:
reasons["handle_frames"] = f"clip guides round DOWN to 17k+5: {h_req} -> {handle}"
# generation length
if int(new_frames) > 0:
new = int(new_frames)
length = align_up(handle + new)
if length != handle + new:
reasons["length"] = f"{handle} handle + {new} new = {handle + new}, rounded UP to {length} (core align_frame_count)"
else:
length = align_up(int(segment_frames))
if length != int(segment_frames):
reasons["length"] = f"segment_frames {segment_frames} rounded UP to {length} (core align_frame_count)"
new = length - handle
surplus = length - handle - new
# anchors
v = visual_anchor
if v == "auto":
if handle == 0:
v = "none"; reasons["visual_anchor"] = "none: scene cut carries no handle"
elif caps.get("per_token_masks"):
v = "per_token_mask"
reasons["visual_anchor"] = ("per_token_mask: the tail written into the target latent under the per-token "
"freeze (#15375); measured 2026-08-23 it made the join an ordinary frame transition "
"where the image guide flipped the camera")
else:
v = "guide"; reasons["visual_anchor"] = "guide: per-token masks not found on this core, falling back to MiniMaxH3AddGuide"
elif v == "per_token_mask" and not caps.get("per_token_masks"):
v = "guide"
reasons["visual_anchor"] = "per_token_mask requested but the running core lacks #15375; fell back to guide"
a = audio_anchor
if a == "auto":
a = "guide" if handle > 0 else "regenerated"
reasons["audio_anchor"] = "guide rides the same AddGuide call as the video handle" if handle > 0 else "no handle, audio regenerates"
if a == "guide" and not caps.get("audio_guide", True):
a = "regenerated"
reasons["audio_anchor"] = "AddGuide has no audio input on this core; audio regenerates"
# spans
src_start = max(0, int(previous_length) - handle)
source = Span.make(src_start, int(previous_length), tb) if handle else Span.make(0, 0, tb)
dest = Span.make(0, handle, tb)
if handle >= 5 and src_start % 17 != 0:
notes.append(f"handle source starts at {src_start}, not a 17-multiple: token phase differs between source and destination "
f"(the atom starts at 102 = 6x17). Legal, but unmeasured")
for name, n in (("length", length), ("handle", handle), ("new", new)):
if not tb.clock_aligned(n):
notes.append(f"{name} = {n} frames is {tb.ticks(n)} audio ticks, not an integer: "
f"exactly 8.333 ms of audio-length error per segment on a legal 17k+5 length (core rounds to the nearest 40 Hz tick; 12.5 ms is the bound for arbitrary lengths), accumulates in a chain. Use frames divisible by 3 (51m+39: 39, 90, 141, 192, 243)")
if surplus:
notes.append(f"{surplus} surplus frame(s) generated beyond the requested new material; H3 Trim drops them")
hd = Handle(role="extension_prefix", source=source, destination=dest, protected=True,
retime_allowed=False, ownership="source", visual_anchor=v, audio_anchor=a, notes=notes)
resolved = {"length": length, "handle_frames": handle, "new_frames": new, "surplus_frames": surplus,
"expand_to_end": bool(final_segment),
"protect_suffix": 0 if final_segment else 17,
"visual_anchor": v, "audio_anchor": a,
"length_ticks": str(tb.ticks(length)), "handle_ticks": str(tb.ticks(handle)),
"new_ticks": str(tb.ticks(new))}
plan = {"schema": SCHEMA, "kind": "extension_plan", "timebase": tb.to_dict(),
"requested": requested, "resolved": resolved, "reasons": reasons,
"handle": hd.to_dict(),
"capabilities": {k: caps.get(k) for k in ("comfy_commit", "per_token_masks", "clip_guide",
"audio_guide", "tokenizer_special_tokens")}}
plan["digest"] = digest({k: plan[k] for k in ("timebase", "resolved", "handle")})
def tk(n):
t = tb.ticks(n)
return f"{t} ticks" + ("" if t.denominator == 1 else " NOT INTEGER")
rep = ["H3 Extension Plan",
f" generate {length:4d} frames {tk(length)} ({tb.seconds(length):.3f} s)",
f" handle {handle:4d} frames {tk(handle)} source [{source.start},{source.end}) -> destination [0,{handle})",
f" new material {new:4d} frames {tk(new)}",
f" surplus {surplus:4d} frames (trimmed)",
f" visual anchor {v.upper()}",
f" audio anchor {a.upper()}",
f" handle protected, retime not allowed, full H3 VAE, never noised",
f" expand_to_end {'ON (final segment)' if final_segment else 'OFF (another segment continues this one)'}",
f" protect suffix {'none (final segment)' if final_segment else '17 frames held at 1: a burst that runs into the cut must not be dilated (measured 2026-08-23: hold 4 on the last 9 frames played the closing gesture 1.55x fast after recovery)'}"]
for k, why in reasons.items():
rep.append(f" resolved {k}: {why}")
for n in notes:
rep.append(f" NOTE {n}")
if caps.get("tokenizer_special_tokens") is False:
rep.append(" WARNING this core predates #15808: dialogue tags tokenize wrong; update before judging audio")
text = "\n".join(rep)
log.info("\n" + text)
return (json.dumps(plan), int(length), int(handle), int(new), text, bool(final_segment))
def _audio_slice(audio, start_frames, n_frames, tb: Timebase):
"""Sample-exact slice of an AUDIO dict for frames [start, start+n)."""
sr = int(audio["sample_rate"])
wf = audio["waveform"]
tb2 = Timebase(tb.fps_num, tb.fps_den, tb.audio_hz, sr)
s0 = tb2.samples(start_frames)
n = tb2.samples(n_frames)
warn = []
if s0.denominator != 1 or n.denominator != 1:
warn.append(f"audio slice not sample-exact at {sr} Hz / {tb.fps} fps (start {s0}, length {n}); rounded")
a = int(round(s0)); b = a + int(round(n))
out = wf[..., a:b]
if out.shape[-1] < int(round(n)):
warn.append(f"audio shorter than the frames it should cover: {out.shape[-1]} of {int(round(n))} samples")
return {"waveform": out, "sample_rate": sr}, warn
class H3TailContext:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"images": ("IMAGE",), "plan": ("STRING", {"forceInput": True})},
"optional": {"audio": ("AUDIO",)}}
RETURN_TYPES = ("IMAGE", "AUDIO", "INT", "STRING", "STRING")
RETURN_NAMES = ("tail_images", "tail_audio", "guide_frame_idx", "handle", "report")
FUNCTION = "tail"
CATEGORY = "MAINodes/alpha"
DESCRIPTION = (
"EXPERIMENTAL (alpha). The last HANDLE frames of a finished (recovered) segment and the "
"sample-exact matching audio, to wire into MiniMaxH3AddGuide (image + audio, frame_idx 0) "
"of the next segment. Full H3 VAE on the way in, no noise, no TAE: this is the seam.")
def tail(self, images, plan, audio=None):
p = json.loads(plan)
tb = Timebase.from_dict(p["timebase"])
hd = Handle.from_dict(p["handle"])
h = hd.destination.frames
n = int(images.shape[0])
warn = []
if h == 0:
tail = images[-1:]
warn.append("scene cut: handle is 0 frames; passing the last frame for reference only, do not wire it as a guide")
else:
if n < h:
warn.append(f"segment has {n} frames, handle wants {h}; using all of them")
tail = images[-min(h, n):]
if hd.source.frames and hd.source.end != n:
warn.append(f"plan placed the handle source at [{hd.source.start},{hd.source.end}) but the segment has {n} frames; slicing from the real end")
if audio is not None and h:
ta, w = _audio_slice(audio, n - tail.shape[0], tail.shape[0], tb)
warn += w
elif audio is not None:
ta = audio
else:
ta = {"waveform": torch.zeros(1, 1, 0), "sample_rate": 48000}
warn.append("no audio wired: the audio handle is empty, the next segment regenerates audio from zero")
hd.source = Span.make(n - tail.shape[0], n, tb)
rep = [f"H3 Tail Context: {tail.shape[0]} frames [{hd.source.start},{hd.source.end}) of {n}, "
f"{tb.ticks(tail.shape[0])} ticks, audio {ta['waveform'].shape[-1]} samples @ {ta['sample_rate']} Hz -> guide at frame 0"]
rep += [" WARNING " + w for w in warn]
text = "\n".join(rep)
log.info(text)
return (tail, ta, 0, json.dumps(hd.to_dict()), text)
class H3ProtectPrefix:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"hold_map": ("STRING", {"forceInput": True}),
"plan": ("STRING", {"forceInput": True})}}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("hold_map", "report")
FUNCTION = "protect"
CATEGORY = "MAINodes/alpha"
DESCRIPTION = (
"EXPERIMENTAL (alpha). Forces hold 1 over the carried prefix so the de-rope may not retime "
"the continuity anchor; the oracle still saw it and so still knows the incoming velocity. "
"Same idea as the handles of H3 Segment Crop and the window overlap. Background freeze on "
"H3 V2V Init stays a separate, independent decision.")
def protect(self, hold_map, plan):
hm = json.loads(hold_map)
p = json.loads(plan)
h = Handle.from_dict(p["handle"]).destination.frames
suf = int(p.get("resolved", {}).get("protect_suffix", 0))
holds = list(hm["holds"])
before = sum(int(x) for x in holds[:h])
for i in range(min(h, len(holds))):
holds[i] = 1
before_suf = sum(int(x) for x in holds[len(holds) - suf:]) if suf else 0
for i in range(max(0, len(holds) - suf), len(holds)):
holds[i] = 1
hm["holds"] = holds
hm.setdefault("protected_prefix", h)
hm.setdefault("protected_suffix", suf)
text = (f"H3 Protect Prefix: holds[:{h}] = 1 ({before - min(h, len(holds))} dilated frames removed from the prefix"
+ (f"), holds[-{suf}:] = 1 ({before_suf - suf} removed from the suffix" if suf else "")
+ f"; {sum(int(x) for x in holds)} dilated frames remain of {len(holds)})")
log.info(text)
return (json.dumps(hm), text)
class H3Trim:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"images": ("IMAGE",), "plan": ("STRING", {"forceInput": True}),
"global_start": ("INT", {"default": 0, "min": 0, "max": 10**7,
"tooltip": "frames already on the assembled timeline before this segment's new material"})},
"optional": {"audio": ("AUDIO",)}}
RETURN_TYPES = ("IMAGE", "AUDIO", "INT", "STRING", "STRING", "IMAGE", "AUDIO")
RETURN_NAMES = ("images", "audio", "global_end", "global_span", "report", "prefix_images", "prefix_audio")
FUNCTION = "trim"
CATEGORY = "MAINodes/alpha"
DESCRIPTION = (
"EXPERIMENTAL (alpha). Drops the hidden handle prefix and any grid surplus from a recovered "
"segment, audio sample-exact, and reports where the kept material sits on the global timeline. "
"All trims map to ONE global clock; the final requested duration is one trim after assembly.")
def trim(self, images, plan, global_start, audio=None):
p = json.loads(plan)
tb = Timebase.from_dict(p["timebase"])
h = int(p["resolved"]["handle_frames"])
new = int(p["resolved"]["new_frames"])
n = int(images.shape[0])
warn = []
if n < h + new:
warn.append(f"recovered segment has {n} frames, plan expected {h + new}; keeping what exists after the prefix")
keep = images[h:h + new]
prefix = images[:h]
if audio is not None:
ta, w = _audio_slice(audio, h, keep.shape[0], tb)
warn += w
pa, _ = _audio_slice(audio, 0, h, tb)
else:
ta = None
pa = None
gs = Span.make(int(global_start), int(global_start) + keep.shape[0], tb)
text = (f"H3 Trim: dropped {h} prefix + {max(0, n - h - keep.shape[0])} surplus, kept {keep.shape[0]} frames "
f"({tb.ticks(keep.shape[0])} ticks) -> global [{gs.start},{gs.end})")
if warn:
text += "\n" + "\n".join(" WARNING " + w for w in warn)
log.info(text)
return (keep, ta, int(gs.end), json.dumps(gs.to_dict()), text, prefix, pa)
class H3PrefixFreezeMask:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"plan": ("STRING", {"forceInput": True}),
"dilated_length": ("INT", {"default": 0, "min": 0, "max": 36000, "forceInput": True,
"tooltip": "H3 Time Smear's length output: the dilated clip the mask rides on"})}}
RETURN_TYPES = ("MASK", "STRING")
RETURN_NAMES = ("mask", "report")
FUNCTION = "mask"
CATEGORY = "MAINodes/alpha"
DESCRIPTION = (
"EXPERIMENTAL (alpha). A time-varying freeze mask for H3 V2V Init (wire it to `mask`, set "
"`time_varying` on): the carried prefix frames are 0 (keep the init, no re-denoise), everything "
"after is 1 (regenerate). With H3 Protect Prefix holding the prefix at 1, the prefix occupies the "
"first HANDLE frames of the dilated clip exactly, and 39 frames is token-exact on the 17k+5 grid. "
"Without this, pass 2 re-textures the seam it was supposed to preserve (measured 2026-08-23: "
"handle MAE 20/255 with hold-1 only).")
def mask(self, plan, dilated_length):
h = Handle.from_dict(json.loads(plan)["handle"]).destination.frames
T = int(dilated_length)
if T <= 0:
T = h + 1
m = torch.ones(T, 8, 8)
m[:min(h, T)] = 0.0
text = f"H3 Prefix Freeze Mask: frames [0,{min(h, T)}) frozen of {T} dilated frames; wire to H3 V2V Init mask with time_varying ON"
log.info(text)
return (m, text)
def _srgb_to_linear(x):
return torch.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4)
def _linear_to_srgb(x):
return torch.where(x <= 0.0031308, x * 12.92, 1.055 * x.clamp(min=0) ** (1 / 2.4) - 0.055)
class H3SeamNormalize:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"source_tail": ("IMAGE", {"tooltip": "H3 Tail Context's tail_images: the accepted world"}),
"generated_prefix": ("IMAGE", {"tooltip": "H3 Trim's prefix_images: the same frames as the continuation rendered them"}),
"images": ("IMAGE", {"tooltip": "H3 Trim's images: the new material to conform"}),
"mode": (["channels (linear-light RGB gains)", "luma (one gain)", "off (report only)"], {"default": "channels (linear-light RGB gains)"}),
"max_gain": ("FLOAT", {"default": 1.25, "min": 1.0, "max": 2.0, "step": 0.01}),
}, "optional": {
"source_tail_audio": ("AUDIO",), "generated_prefix_audio": ("AUDIO",), "audio": ("AUDIO",),
"audio_mode": (["off", "gain (match the handle's rms)"], {"default": "off",
"tooltip": "off by default: measured 2026-08-23 the handle-fitted rms gain made the seam step WORSE (-8 -> -11 dB); the rendered prefix was louder than its source while the new material was quieter, so audio level is not a uniform offset like the VAE darkening"}),
"audio_fade_ms": ("INT", {"default": 10, "min": 0, "max": 200}),
}}
RETURN_TYPES = ("IMAGE", "AUDIO", "STRING")
RETURN_NAMES = ("images", "audio", "report")
FUNCTION = "normalize"
CATEGORY = "MAINodes/alpha"
DESCRIPTION = (
"EXPERIMENTAL (alpha). The hidden prefix is calibration data: 39 frames the continuation rendered "
"of content whose accepted version exists. Fit per-channel linear-light gains (or one luma gain) "
"that map the rendered prefix onto the source tail, apply them to the NEW material so the new "
"segment conforms to the accepted world (never the other way round), and do the same with an rms "
"gain on the audio plus a short fade-in. Measured 2026-08-23: each VAE round-trip on the masked "
"path darkens the prefix ~2.4% and the new material lands warmer (G -7%, B -11%): the colour gains "
"removed the luma step (-4.2% -> -0.1%) and half the chroma shift. The audio rms gain did NOT help "
"(seam step -8 -> -11 dB) and defaults to off. No network, no render.")
def normalize(self, source_tail, generated_prefix, images, mode, max_gain,
source_tail_audio=None, generated_prefix_audio=None, audio=None,
audio_mode="gain (match the handle's rms)", audio_fade_ms=10):
n = min(source_tail.shape[0], generated_prefix.shape[0])
src = _srgb_to_linear(source_tail[-n:].float().clamp(0, 1))
gen = _srgb_to_linear(generated_prefix[:n].float().clamp(0, 1))
w = torch.tensor([0.2126, 0.7152, 0.0722])
gains = torch.ones(3)
if mode.startswith("channels"):
gains = (src.reshape(-1, 3).median(0).values + 1e-4) / (gen.reshape(-1, 3).median(0).values + 1e-4)
elif mode.startswith("luma"):
g = float(((src * w).sum(-1).mean() + 1e-4) / ((gen * w).sum(-1).mean() + 1e-4))
gains = torch.full((3,), g)
gains = gains.clamp(1 / max_gain, max_gain)
if mode.startswith("off"):
out = images
else:
lin = _srgb_to_linear(images.float().clamp(0, 1)) * gains
out = _linear_to_srgb(lin.clamp(0, 1)).to(images.dtype)
resid = ((gen * gains).reshape(-1, 3).median(0).values - src.reshape(-1, 3).median(0).values).abs().max().item()
rep = [f"H3 Seam Normalize: {mode.split(' ')[0]} gains R {gains[0]:.4f} G {gains[1]:.4f} B {gains[2]:.4f} "
f"(fitted on {n} hidden frames; residual median error after correction {resid:.4f})"]
ao = audio
if audio is not None and source_tail_audio is not None and generated_prefix_audio is not None and audio_mode.startswith("gain"):
rs = source_tail_audio["waveform"].float().pow(2).mean().sqrt().item()
rg = generated_prefix_audio["waveform"].float().pow(2).mean().sqrt().item()
ag = max(0.25, min(4.0, (rs + 1e-6) / (rg + 1e-6)))
wf = audio["waveform"].float() * ag
sr = int(audio["sample_rate"]); nf = int(sr * audio_fade_ms / 1000)
if nf > 0 and wf.shape[-1] > nf:
ramp = torch.linspace(0, 1, nf)
wf[..., :nf] = wf[..., :nf] * ramp
ao = {"waveform": wf.to(audio["waveform"].dtype), "sample_rate": sr}
rep.append(f" audio gain {ag:.3f} ({20 * math.log10(ag):+.1f} dB; handle rms source {rs:.4f} vs rendered {rg:.4f}), fade-in {audio_fade_ms} ms")
text = "\n".join(rep)
log.info(text)
return (out, ao, text)
NODE_CLASS_MAPPINGS = {
"H3ExtensionPlan": H3ExtensionPlan,
"H3TailContext": H3TailContext,
"H3ProtectPrefix": H3ProtectPrefix,
"H3Trim": H3Trim,
"H3PrefixFreezeMask": H3PrefixFreezeMask,
"H3SeamNormalize": H3SeamNormalize,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"H3ExtensionPlan": "H3 Extension Plan (alpha)",
"H3TailContext": "H3 Tail Context (alpha)",
"H3ProtectPrefix": "H3 Protect Prefix (alpha)",
"H3Trim": "H3 Trim (alpha)",
"H3PrefixFreezeMask": "H3 Prefix Freeze Mask (alpha)",
"H3SeamNormalize": "H3 Seam Normalize (alpha)",
}
|