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