File size: 11,474 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
"""H3 Drift Control (alpha): schedule-matched noise on the carried video prefix.

A chained segment's carried prefix is bit-clean conditioning. One seam loves
that; a CHAIN does not: repeated clean conditioning accumulates contrast and
texture error, and our seam probe measures the decay directly (join1 ~0.9,
join2 ~0.65 across every variant tested 2026-08-24). The fix is to give the
disposable carried video prefix a small, scheduler-matched amount of the
sampler's own noise at every model evaluation, tapering to exact at the seam
end, so the model never treats the prefix as impossibly clean.

Mechanism and recipe ADAPTED from ComfyUI-MiniMaxH3-Contex-Loop's
drift_control.py (NikoDemon80 / ethanfel, GPL-3.0) - same license as this
pack. Their doctrine, kept: the dynamic mask must be installed in TWO places
that agree (ComfyUI's denoise-mask function AND an apply-model wrapper),
because without the second path H3 labels the prefix clean while receiving a
noisy input. Their sigma-split sampler handoff wrapper is deliberately NOT
ported: our graphs run one SamplerCustomAdvanced per pass. Audio is
deliberately untouched - the frozen per-tick audio prefix stays hard.

Wire: H3 V2V Init latent -> here (for shape + mask presence), the SAME model
your pass-1 guider uses -> here, guider takes THIS node's model out.
prefix_frames must equal the plan's handle (39 frames = 12 video steps).
"""

import math

import torch

NODE_NAME = "H3DriftControl"
MASK_QUANTIZATION = 256.0


# ------------------------------------------------------------- pure functions

def _schedule_values(sigmas):
    """Finite, non-negative schedule values, descending, deduplicated."""
    if torch.is_tensor(sigmas):
        values = sigmas.detach().float().reshape(-1).cpu().tolist()
    else:
        values = list(sigmas or ())
    out = set()
    for v in values:
        v = float(v)
        if math.isfinite(v) and v >= 0.0:
            out.add(v)
    return tuple(sorted(out, reverse=True))


def next_schedule_sigma(current_sigma, sigmas):
    """The next strictly lower sigma in the schedule, else 0."""
    current = float(current_sigma)
    if not math.isfinite(current) or current <= 0.0:
        return 0.0
    tol = max(1e-7, abs(current) * 1e-6)
    for candidate in _schedule_values(sigmas):
        if candidate < current - tol:
            return candidate
    return 0.0


def matched_noise_ratio(current_sigma, sigmas):
    """next_sigma / current_sigma clamped to [0, 1]."""
    current = float(current_sigma)
    if not math.isfinite(current) or current <= 0.0:
        return 0.0
    return max(0.0, min(1.0, next_schedule_sigma(current, sigmas) / current))


def temporal_prefix_weights(prefix_steps, taper_steps):
    """Matched weights 1.0 over the old prefix, tapering to 0 at the seam end.

    taper 4 gives .75 .50 .25 .00: the LAST carried latent step stays exact at
    the generated-future boundary."""
    count, taper = int(prefix_steps), int(taper_steps)
    if count < 1:
        raise ValueError("drift control: prefix_steps must be positive")
    if taper < 1 or taper > count:
        raise ValueError("drift control: taper_steps must be in [1, prefix_steps]")
    weights = [1.0] * (count - taper)
    weights.extend(float(taper - i - 1) / float(taper) for i in range(taper))
    return tuple(weights)


def apply_dynamic_prefix_mask(packed_mask, video_shape, prefix_steps, ratio,
                              taper_steps):
    """Rewrite the video-prefix region of a packed [B,1,N] denoise mask.

    Returns (packed mask, one-channel H3 video mask). The H3 mask is
    ceil-quantized to 1/256 like core's merged-mask path, so both install
    points agree on native and compatibility cores."""
    if not torch.is_tensor(packed_mask) or packed_mask.ndim != 3:
        raise ValueError("drift control: sampler denoise mask must be [B,1,N]")
    shape = tuple(int(v) for v in video_shape)
    if len(shape) != 5 or shape[0] != int(packed_mask.shape[0]):
        raise ValueError("drift control: expected video latent [B,C,T,H,W], got %s"
                         % (shape,))
    steps = int(prefix_steps)
    if steps < 1 or steps > shape[2]:
        raise ValueError("drift control: prefix %d steps outside the %d-step latent"
                         % (steps, shape[2]))
    video_elements = math.prod(shape[1:])
    if int(packed_mask.shape[-1]) < video_elements:
        raise ValueError("drift control: packed mask shorter than its video stream")

    out = packed_mask.clone()
    video = out[..., :video_elements].reshape(shape)
    weights = torch.tensor(temporal_prefix_weights(steps, taper_steps),
                           device=video.device, dtype=video.dtype)
    weights = weights.mul_(float(max(0.0, min(1.0, ratio))))
    video[:, :, :steps] = weights.view(1, 1, steps, 1, 1)
    h3_video_mask = torch.ceil(video[:, :1].float() * MASK_QUANTIZATION) / MASK_QUANTIZATION
    return out, h3_video_mask


def frames_to_steps(frames):
    """17k+5 pixel frames -> 5k+2 video latent steps (39 -> 12)."""
    f = int(frames)
    if f < 5 or (f - 5) % 17:
        raise ValueError("drift control: prefix_frames must sit on the 17k+5 grid "
                         "(39, 56, 90, ...); got %d" % f)
    return 5 * ((f - 5) // 17) + 2


# ---------------------------------------------------------------- hook state

class _DriftState:
    def __init__(self, video_shape, prefix_steps, matched_steps, taper_steps,
                 schedule_override=None):
        if int(prefix_steps) != int(matched_steps) + int(taper_steps):
            raise ValueError(
                "drift control: prefix has %d video steps but matched %d + "
                "taper %d were asked for - they must sum exactly"
                % (prefix_steps, matched_steps, taper_steps))
        self.video_shape = tuple(int(v) for v in video_shape)
        self.prefix_steps = int(prefix_steps)
        self.taper_steps = int(taper_steps)
        self.schedule_override = _schedule_values(schedule_override)
        self.current_video_mask = None
        self.last_sigma = None
        self.last_ratio = None

    def denoise_mask_function(self, sigma, denoise_mask, extra_options=None):
        current = float(torch.as_tensor(sigma).detach().float().reshape(-1)[0])
        schedule = self.schedule_override or (extra_options or {}).get("sigmas", ())
        ratio = matched_noise_ratio(current, schedule)
        out, video_mask = apply_dynamic_prefix_mask(
            denoise_mask, self.video_shape, self.prefix_steps, ratio,
            self.taper_steps)
        self.current_video_mask = video_mask
        self.last_sigma, self.last_ratio = current, ratio
        return out

    def apply_model_wrapper(self, executor, *args, **kwargs):
        # H3's per-row timestep conditioning must see the SAME dynamic video
        # mask the sampler mixes with, or the prefix is labeled clean while
        # receiving noisy input (the two-install-points doctrine).
        if self.current_video_mask is not None:
            kwargs["denoise_mask"] = self.current_video_mask
        return executor(*args, **kwargs)


# --------------------------------------------------------------------- node

class H3DriftControl:
    """Clone the model and install the coupled dynamic-prefix-mask hooks."""

    DESCRIPTION = (
        "EXPERIMENTAL (alpha). Schedule-matched noise on the carried VIDEO "
        "prefix of a chained segment, tapering to exact at the seam end - "
        "stops clean-conditioning error accumulating down a chain (measured "
        "join decay 0.9 -> 0.65 by link two, 2026-08-24). Wire the H3 V2V "
        "Init latent here for shape, feed the SAME base model your pass-1 "
        "guider uses, and give the guider THIS model output. prefix_frames "
        "must equal the plan's handle (39 f = 12 steps = matched 8 + taper "
        "4, the field-validated recipe). Audio is untouched: the frozen "
        "per-tick audio prefix stays hard. Adapted from Contex-Loop's "
        "Drift-Control AV (GPL-3.0). Refuses to stack on another dynamic "
        "denoise-mask patch, and does not support sigma-split samplers.")

    @classmethod
    def INPUT_TYPES(cls):
        return {"required": {
            "model": ("MODEL", {"tooltip": "the base H3 model your pass-1 guider would use"}),
            "latent": ("LATENT", {"tooltip": "H3 V2V Init output: supplies the video shape and must carry the static noise_mask"}),
        }, "optional": {
            "prefix_frames": ("INT", {"default": 39, "min": 5, "max": 3600,
                "tooltip": "carried handle in pixel frames, on the 17k+5 grid; 39 -> 12 latent steps"}),
            "matched_steps": ("INT", {"default": 8, "min": 0, "max": 400,
                "tooltip": "older prefix steps held at full matched noise"}),
            "taper_steps": ("INT", {"default": 4, "min": 1, "max": 400,
                "tooltip": "steps ramping matched -> exact toward the seam end; matched+taper must equal the prefix's latent steps"}),
            "full_sigmas": ("SIGMAS", {"tooltip": "optional: the complete schedule if the sampler only sees a partial one; usually leave unwired"}),
        }}

    RETURN_TYPES = ("MODEL", "STRING")
    RETURN_NAMES = ("model", "report")
    FUNCTION = "install"
    CATEGORY = "MAINodes/alpha"

    def install(self, model, latent, prefix_frames=39, matched_steps=8,
                taper_steps=4, full_sigmas=None):
        samples = latent.get("samples") if isinstance(latent, dict) else None
        if hasattr(samples, "is_nested") and samples.is_nested:
            video = samples.tensors[0]
        elif torch.is_tensor(samples):
            video = samples
        else:
            raise ValueError("drift control: latent has no video tensor")
        if "noise_mask" not in latent:
            raise ValueError(
                "drift control: the latent carries no noise_mask - wire H3 V2V "
                "Init (masked prefix) first; drift control rewrites that mask, "
                "it does not create one")
        steps = frames_to_steps(prefix_frames)

        patched = model.clone()
        options = getattr(patched, "model_options", None)
        if not isinstance(options, dict):
            raise ValueError("drift control: model has no model_options")
        if callable(options.get("denoise_mask_function")):
            raise ValueError(
                "drift control: another dynamic denoise-mask patch is already "
                "installed on this model - they cannot stack")

        from comfy.patcher_extension import WrappersMP
        state = _DriftState(tuple(video.shape), steps, matched_steps,
                            taper_steps, schedule_override=full_sigmas)
        patched.set_model_denoise_mask_function(state.denoise_mask_function)
        patched.add_wrapper_with_key(WrappersMP.APPLY_MODEL,
                                     "mainodes_h3_drift_control",
                                     state.apply_model_wrapper)
        patched.model_options["mainodes_h3_drift_control_state"] = state
        text = ("H3 Drift Control: %d-step video prefix = %d matched + %d "
                "taper, schedule-matched ratio per step; audio untouched"
                % (steps, matched_steps, taper_steps))
        print("[MAINodes] " + text)
        return (patched, text)


NODE_CLASS_MAPPINGS = {NODE_NAME: H3DriftControl}
NODE_DISPLAY_NAME_MAPPINGS = {NODE_NAME: "H3 Drift Control (alpha)"}