dagloop5 commited on
Commit
0925136
·
verified ·
1 Parent(s): 4649024

Upload pk_workflow.py

Browse files
Files changed (1) hide show
  1. pk_workflow.py +470 -0
pk_workflow.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The three things that make `Plaguekind/Minimax-H3` a *workflow* rather than just MiniMax-H3, ported onto the
2
+ `ref2va` Space.
3
+
4
+ Trimmed from the fl2va Space's `pk_workflow.py`: only the plain `scheduler.step()` samplers are here —
5
+ `euler` (no patch at all), `euler_ancestral`, and `er_sde`. The SDE-family samplers (`dpmpp_2m_sde_gpu`,
6
+ `dpmpp_3m_sde_gpu`) and the two-evaluation-per-step samplers (`dpmpp_2s_ancestral`, `dpmpp_sde_gpu`, `seeds_2`,
7
+ in `h3_dpmpp_2s_ancestral.py` on the fl2va Space) aren't ported here, so neither is `torchsde` or the Brownian-tree
8
+ noise machinery either sampler family needs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import torch
14
+
15
+
16
+ # ----------------------------------------------------------------------------------------------------------------
17
+ # BasicScheduler(linear_quadratic)
18
+ # ----------------------------------------------------------------------------------------------------------------
19
+ # MiniMax-H3 carries two rectified-flow schedules per request, `shift = 12` for the video rows and `shift = 3` for
20
+ # the audio rows. diffusers builds both from one `linspace(1, 0, steps)` base grid; ComfyUI instead samples the
21
+ # *video* schedule and derives the audio one from it in closed form
22
+ # (`comfy/ldm/minimax/model.py::time_shift_sigma`). The two agree, because the shift is a bijection of the base
23
+ # grid — which is what lets a schedule chosen in ComfyUI's video-sigma space be transplanted here exactly.
24
+ #
25
+ # `linear_quadratic` is Mochi's schedule (`comfy/samplers.py::linear_quadratic_schedule`) and it does **not** go
26
+ # through the model's shift at all: it is `sigma_max = 1.0` scaled, so the grid PlagueKind's 15 steps actually run
27
+ # is this one verbatim, in the video stream, with the audio stream shifted off it.
28
+ VIDEO_SHIFT = 12.0
29
+ AUDIO_SHIFT = 3.0
30
+
31
+
32
+ def linear_quadratic_sigmas(
33
+ steps: int, threshold_noise: float = 0.025, linear_steps: int | None = None
34
+ ) -> torch.Tensor:
35
+ """ComfyUI's `linear_quadratic` sigma grid, in MiniMax-H3's video-sigma space.
36
+
37
+ Ported from `comfy/samplers.py::linear_quadratic_schedule` (itself from Mochi), with
38
+ `model_sampling.sigma_max == 1.0`, which is what a rectified-flow model has. Returns `steps + 1` strictly
39
+ decreasing sigmas from exactly 1.0 to exactly 0.0, so it drives `steps` forwards — ComfyUI's step count, not
40
+ diffusers' (where the terminal zero is one of the `num_inference_steps`).
41
+
42
+ Half the steps crawl through the first 2.5% of the trajectory and the rest sprint the remaining 97.5%: it is a
43
+ front-loaded schedule, which is why 15 steps of it hold up against ~28 of the native grid.
44
+ """
45
+ steps = int(steps)
46
+ if steps < 2:
47
+ return torch.tensor([1.0, 0.0], dtype=torch.float32)
48
+ if linear_steps is None:
49
+ linear_steps = steps // 2
50
+
51
+ linear = [i * threshold_noise / linear_steps for i in range(linear_steps)]
52
+ threshold_noise_step_diff = linear_steps - threshold_noise * steps
53
+ quadratic_steps = steps - linear_steps
54
+ quadratic_coef = threshold_noise_step_diff / (linear_steps * quadratic_steps**2)
55
+ linear_coef = threshold_noise / linear_steps - 2 * threshold_noise_step_diff / (quadratic_steps**2)
56
+ const = quadratic_coef * (linear_steps**2)
57
+ quadratic = [quadratic_coef * (i**2) + linear_coef * i + const for i in range(linear_steps, steps)]
58
+
59
+ schedule = linear + quadratic + [1.0]
60
+ return torch.tensor([1.0 - value for value in schedule], dtype=torch.float32)
61
+
62
+
63
+ def time_shift_sigma(sigma: torch.Tensor, from_shift: float, to_shift: float) -> torch.Tensor:
64
+ """Move a sigma between two exponential shifts of the same base grid.
65
+
66
+ `comfy/ldm/minimax/model.py::time_shift_sigma`: invert `sigma = s*b / (1 + (s-1)*b)` back to the base grid `b`,
67
+ then re-apply the other shift. Monotonic, and it fixes both 0.0 and 1.0, so a strictly decreasing schedule that
68
+ ends at zero stays one.
69
+ """
70
+ if from_shift == to_shift:
71
+ return sigma
72
+ base = sigma / (from_shift + sigma * (1.0 - from_shift))
73
+ return to_shift * base / (1.0 + (to_shift - 1.0) * base)
74
+
75
+
76
+ # ----------------------------------------------------------------------------------------------------------------
77
+ # BasicScheduler(sgm_uniform / simple / beta / ddim_uniform / normal)
78
+ # ----------------------------------------------------------------------------------------------------------------
79
+ # Five more of ComfyUI's `BasicScheduler` names, ported from `comfy/samplers.py`. Each is computed at the
80
+ # *reference* shift (1.0 — where `time_snr_shift` is the identity, so `sigma(t) == t`) and reprojected onto each
81
+ # scheduler's real shift by `time_shift_sigma`, exactly like `linear_quadratic_sigmas` already is and for the same
82
+ # reason: it keeps the video and audio streams pinned to the same underlying denoising progress at each step,
83
+ # which computing each stream's schedule independently at its own shift would not.
84
+ #
85
+ # `FLOW_TIMESTEPS` mirrors ComfyUI's `ModelSamplingDiscreteFlow`/`ModelSamplingAV` default of 1000 discrete steps
86
+ # (`comfy/model_sampling.py`). Unverified specifically for MiniMax-H3's own `sampling_settings` — if a ported
87
+ # schedule's shape looks visibly different from ComfyUI's own render at the same steps/seed, this is the first
88
+ # thing to check.
89
+ FLOW_TIMESTEPS = 1000
90
+
91
+
92
+ def _reference_sigma(index_1based: int) -> float:
93
+ """`ModelSamplingAV.sigma(timestep)` at shift == 1.0: the shift formula is the identity, so this is just the
94
+ plain fraction `index / FLOW_TIMESTEPS`. `index_1based` matches ComfyUI's 1-based table construction
95
+ (`torch.arange(1, timesteps + 1) / timesteps`)."""
96
+ return index_1based / FLOW_TIMESTEPS
97
+
98
+
99
+ def sgm_uniform_sigmas(steps: int) -> torch.Tensor:
100
+ """ComfyUI's `sgm_uniform`. Uniform in *timestep* space between the max and min sigma, dropping the point
101
+ that would land exactly on the minimum, then appending an exact 0.0. `steps + 1` sigmas."""
102
+ steps = int(steps)
103
+ timesteps = torch.linspace(float(FLOW_TIMESTEPS), 1.0, steps + 1)[:-1]
104
+ sigmas = (timesteps / FLOW_TIMESTEPS).tolist() + [0.0]
105
+ return torch.tensor(sigmas, dtype=torch.float32)
106
+
107
+
108
+ def normal_sigmas(steps: int) -> torch.Tensor:
109
+ """ComfyUI's `normal`. Same idea as `sgm_uniform` but the linspace includes both endpoints (the minimum
110
+ sigma is reached exactly, not dropped), with 0.0 still appended."""
111
+ steps = int(steps)
112
+ timesteps = torch.linspace(float(FLOW_TIMESTEPS), 1.0, steps)
113
+ sigmas = (timesteps / FLOW_TIMESTEPS).tolist() + [0.0]
114
+ return torch.tensor(sigmas, dtype=torch.float32)
115
+
116
+
117
+ def simple_sigmas(steps: int) -> torch.Tensor:
118
+ """ComfyUI's `simple`: evenly-spaced *indices* into the 1000-entry sigma table, walked from the high-noise
119
+ end, then 0.0 appended."""
120
+ steps = int(steps)
121
+ stride = FLOW_TIMESTEPS / steps
122
+ sigmas = [_reference_sigma(FLOW_TIMESTEPS - int(x * stride)) for x in range(steps)]
123
+ sigmas.append(0.0)
124
+ return torch.tensor(sigmas, dtype=torch.float32)
125
+
126
+
127
+ def ddim_uniform_sigmas(steps: int) -> torch.Tensor:
128
+ """ComfyUI's `ddim_uniform`: a fixed-stride walk through the sigma table starting one index in, reversed so
129
+ the highest sigma comes first, ending at 0.0."""
130
+ steps = int(steps)
131
+ stride = max(FLOW_TIMESTEPS // steps, 1)
132
+ sigmas = [0.0]
133
+ index = 1
134
+ while index < FLOW_TIMESTEPS:
135
+ sigmas.append(_reference_sigma(index))
136
+ index += stride
137
+ sigmas.reverse()
138
+ return torch.tensor(sigmas, dtype=torch.float32)
139
+
140
+
141
+ def beta_sigmas(steps: int, alpha: float = 0.6, beta: float = 0.6) -> torch.Tensor:
142
+ """ComfyUI's `beta` (arxiv.org/abs/2407.12173): table indices drawn from a Beta(alpha, beta) inverse CDF
143
+ instead of an even stride, biasing samples toward one end of the trajectory. Needs `scipy`."""
144
+ import numpy
145
+ import scipy.stats
146
+
147
+ steps = int(steps)
148
+ total = FLOW_TIMESTEPS - 1
149
+ positions = 1.0 - numpy.linspace(0.0, 1.0, steps, endpoint=False)
150
+ indices = numpy.rint(scipy.stats.beta.ppf(positions, alpha, beta) * total)
151
+ sigmas = []
152
+ last = -1
153
+ for value in indices:
154
+ if value != last:
155
+ sigmas.append(_reference_sigma(int(value) + 1))
156
+ last = value
157
+ sigmas.append(0.0)
158
+ return torch.tensor(sigmas, dtype=torch.float32)
159
+
160
+
161
+ SCHEDULE_SIGMA_FUNCS = {
162
+ "linear_quadratic": linear_quadratic_sigmas,
163
+ "sgm_uniform": sgm_uniform_sigmas,
164
+ "simple": simple_sigmas,
165
+ "beta": beta_sigmas,
166
+ "ddim_uniform": ddim_uniform_sigmas,
167
+ "normal": normal_sigmas,
168
+ }
169
+
170
+
171
+ def _euler_ancestral_step(scheduler, generator, model_output, timestep, sample, eta: float = 1.0, s_noise: float = 1.0):
172
+ """Ports k-diffusion's `sample_euler_ancestral_RF` — the flow-matching branch `sample_euler_ancestral`
173
+ dispatches to for `CONST`-style model sampling, which is what MiniMax-H3's `[0, 1]` sigma space is — onto one
174
+ `MiniMaxH3Scheduler.step()` call. Single model evaluation, same shape as `step()` itself, with fresh
175
+ ancestral noise injected each step instead of a plain Euler blend. Mirrors `step()`'s own care around
176
+ recomputing `sigma_from_timestep` from `timestep` rather than reading `self.sigmas` at the current index, for
177
+ the same numerical-consistency reason documented there.
178
+ """
179
+ if scheduler._step_index is None:
180
+ scheduler._step_index = scheduler.index_for_timestep(timestep) if scheduler._begin_index is None else scheduler._begin_index
181
+
182
+ if not isinstance(timestep, torch.Tensor):
183
+ timestep = torch.tensor(timestep, dtype=sample.dtype)
184
+ sigma_from_timestep = 1 - timestep.to(device=sample.device, dtype=sample.dtype)
185
+ while sigma_from_timestep.ndim < sample.ndim:
186
+ sigma_from_timestep = sigma_from_timestep.unsqueeze(-1)
187
+ denoised = sample + sigma_from_timestep * model_output
188
+
189
+ compute_dtype = torch.float32 if sample.dtype in (torch.float16, torch.bfloat16) else sample.dtype
190
+ sigma = scheduler.sigmas[scheduler._step_index].to(device=sample.device, dtype=compute_dtype)
191
+ sigma_next = scheduler.sigmas[scheduler._step_index + 1].to(device=sample.device, dtype=compute_dtype)
192
+ x = sample.to(dtype=compute_dtype)
193
+ denoised = denoised.to(dtype=compute_dtype)
194
+
195
+ if sigma_next == 0:
196
+ prev_sample = denoised
197
+ else:
198
+ downstep_ratio = 1 + (sigma_next / sigma - 1) * eta
199
+ sigma_down = sigma_next * downstep_ratio
200
+ alpha_next = 1 - sigma_next
201
+ alpha_down = 1 - sigma_down
202
+ renoise_coeff = (sigma_next**2 - sigma_down**2 * alpha_next**2 / alpha_down**2).clamp_min(0).sqrt()
203
+ ratio = sigma_down / sigma
204
+ prev_sample = ratio * x + (1 - ratio) * denoised
205
+ if eta > 0:
206
+ noise = torch.randn(x.shape, dtype=x.dtype, device="cpu", generator=generator).to(x.device)
207
+ prev_sample = (alpha_next / alpha_down) * prev_sample + noise * s_noise * renoise_coeff
208
+
209
+ prev_sample = prev_sample.to(dtype=sample.dtype)
210
+ scheduler._step_index += 1
211
+ return prev_sample
212
+
213
+
214
+ def _er_sde_step(scheduler, generator, model_output, timestep, sample, s_noise: float = 1.0, max_stage: int = 3):
215
+ """Ports k-diffusion's `sample_er_sde` (VP ER-SDE-Solver-3, arXiv:2309.06169) onto one
216
+ `MiniMaxH3Scheduler.step()` call. Single model evaluation per step — second/third-order accuracy comes from
217
+ the previous one or two steps' denoised estimates, not an extra evaluation this step — so it carries history
218
+ on the scheduler instance across calls, reset each request by `use_schedule` alongside `_step_index`.
219
+ """
220
+ if scheduler._step_index is None:
221
+ scheduler._step_index = scheduler.index_for_timestep(timestep) if scheduler._begin_index is None else scheduler._begin_index
222
+ i = scheduler._step_index
223
+
224
+ if not isinstance(timestep, torch.Tensor):
225
+ timestep = torch.tensor(timestep, dtype=sample.dtype)
226
+ sigma_from_timestep = 1 - timestep.to(device=sample.device, dtype=sample.dtype)
227
+ while sigma_from_timestep.ndim < sample.ndim:
228
+ sigma_from_timestep = sigma_from_timestep.unsqueeze(-1)
229
+ denoised = sample + sigma_from_timestep * model_output
230
+
231
+ compute_dtype = torch.float32 if sample.dtype in (torch.float16, torch.bfloat16) else sample.dtype
232
+ sigmas = scheduler.sigmas.to(device=sample.device, dtype=compute_dtype)
233
+ sigma, sigma_next = sigmas[i], sigmas[i + 1]
234
+ x = sample.to(dtype=compute_dtype)
235
+ denoised = denoised.to(dtype=compute_dtype)
236
+
237
+ if i == 0 and float(sigma) >= 1.0:
238
+ # `1 - sigma` sits in a denominator below; MiniMax-H3's first sigma is exactly 1.0, so nudge it a hair
239
+ # under 1.0 for this sampler's math only, matching ComfyUI's `offset_first_sigma_for_snr`. Does not
240
+ # touch `sigma_from_timestep` above — the model was still conditioned on the real timestep.
241
+ base = torch.tensor(1.0 - 1e-4, dtype=compute_dtype, device=sample.device)
242
+ shift = float(scheduler.shift)
243
+ sigma = shift * base / (1 + (shift - 1) * base)
244
+
245
+ def er_lambda(s):
246
+ return s / (1 - s)
247
+
248
+ def noise_scaler(v):
249
+ return v * (v**0.3).exp() + v * 10.0
250
+
251
+ if sigma_next == 0:
252
+ prev_sample = denoised
253
+ else:
254
+ er_lambda_s, er_lambda_t = er_lambda(sigma), er_lambda(sigma_next)
255
+ alpha_s, alpha_t = 1 - sigma, 1 - sigma_next
256
+ r_alpha = alpha_t / alpha_s
257
+ r = noise_scaler(er_lambda_t) / noise_scaler(er_lambda_s)
258
+
259
+ prev_sample = r_alpha * r * x + alpha_t * (1 - r) * denoised
260
+
261
+ stage_used = min(max_stage, i + 1)
262
+ if stage_used >= 2:
263
+ num_points = 200
264
+ dt = er_lambda_t - er_lambda_s
265
+ step_size = -dt / num_points
266
+ positions = er_lambda_t + torch.arange(num_points, device=x.device, dtype=compute_dtype) * step_size
267
+ scaled = noise_scaler(positions)
268
+
269
+ s_term = torch.sum(1 / scaled) * step_size
270
+ er_lambda_prev = er_lambda(sigmas[i - 1])
271
+ denoised_d = (denoised - scheduler._er_sde_old_denoised) / (er_lambda_s - er_lambda_prev)
272
+ prev_sample = prev_sample + alpha_t * (dt + s_term * noise_scaler(er_lambda_t)) * denoised_d
273
+
274
+ if stage_used >= 3:
275
+ s_u_term = torch.sum((positions - er_lambda_s) / scaled) * step_size
276
+ er_lambda_prev2 = er_lambda(sigmas[i - 2])
277
+ denoised_u = (denoised_d - scheduler._er_sde_old_denoised_d) / ((er_lambda_s - er_lambda_prev2) / 2)
278
+ prev_sample = prev_sample + alpha_t * ((dt**2) / 2 + s_u_term * noise_scaler(er_lambda_t)) * denoised_u
279
+ scheduler._er_sde_old_denoised_d = denoised_d
280
+
281
+ if s_noise > 0:
282
+ noise = torch.randn(x.shape, dtype=x.dtype, device="cpu", generator=generator).to(x.device)
283
+ spread = (er_lambda_t**2 - er_lambda_s**2 * r**2).clamp_min(0).sqrt()
284
+ prev_sample = prev_sample + alpha_t * noise * s_noise * spread
285
+
286
+ scheduler._er_sde_old_denoised = denoised
287
+ prev_sample = prev_sample.to(dtype=sample.dtype)
288
+ scheduler._step_index += 1
289
+ return prev_sample
290
+
291
+
292
+ class use_schedule:
293
+ """Set each scheduler's shift for one request, and — for anything but `native` — force its sigma grid onto
294
+ one of `SCHEDULE_SIGMA_FUNCS`'s named schedules.
295
+
296
+ `MiniMaxH3Scheduler.shift` is a read-only property, so a different shift means swapping in a freshly built
297
+ scheduler via `from_config(..., shift=...)` rather than mutating one in place — the standard diffusers idiom
298
+ for changing a `ConfigMixin` parameter after construction, and correct regardless of exactly how `shift` is
299
+ stored internally. Applied unconditionally, including under `native`, so the shift sliders affect the
300
+ pipeline's own default schedule too — and always restored on exit, since `pipe.scheduler`/`pipe.audio_scheduler`
301
+ are shared, request-spanning objects that must not carry one request's shift into the next.
302
+ """
303
+
304
+ def __init__(self, pipe, steps: int, schedule_name: str, video_shift: float, audio_shift: float, sampler_name: str = "euler", seed: int = 0, threshold_noise: float = 0.025):
305
+ self.pipe = pipe
306
+ self.attr_names = ["scheduler", "audio_scheduler"]
307
+ self.shifts = [float(video_shift), float(audio_shift)]
308
+ self.schedule_name = schedule_name
309
+ self.sampler_name = sampler_name
310
+ self.seed = int(seed)
311
+ self.steps = int(steps)
312
+ self.threshold_noise = float(threshold_noise)
313
+ self._originals: dict = {}
314
+
315
+ def __enter__(self):
316
+ for attr_name, shift in zip(self.attr_names, self.shifts):
317
+ original = getattr(self.pipe, attr_name)
318
+ self._originals[attr_name] = original
319
+ if float(original.shift) != shift:
320
+ setattr(self.pipe, attr_name, type(original).from_config(original.config, shift=shift))
321
+
322
+ if self.schedule_name != "native":
323
+ sigma_func = SCHEDULE_SIGMA_FUNCS[self.schedule_name]
324
+ base = sigma_func(self.steps, self.threshold_noise) if sigma_func is linear_quadratic_sigmas else sigma_func(self.steps)
325
+ for attr_name in self.attr_names:
326
+ scheduler = getattr(self.pipe, attr_name)
327
+ sigmas = time_shift_sigma(base, 1.0, float(scheduler.shift))
328
+ unbound = type(scheduler).set_timesteps
329
+
330
+ def forced(num_inference_steps=None, device=None, sigmas=None, _s=scheduler, _grid=sigmas, _f=unbound):
331
+ return _f(_s, None, device, _grid)
332
+
333
+ scheduler.set_timesteps = forced
334
+
335
+ if self.sampler_name == "euler_ancestral":
336
+ # Separate `torch.Generator` per scheduler (offset seeds) so video and audio ancestral noise don't
337
+ # correlate — each generator advances across every step call to *that* scheduler over the request.
338
+ for offset, attr_name in enumerate(self.attr_names):
339
+ scheduler = getattr(self.pipe, attr_name)
340
+ generator = torch.Generator(device="cpu").manual_seed(self.seed + offset)
341
+
342
+ def stepped(model_output, timestep, sample, return_dict=True, _s=scheduler, _g=generator, **_kwargs):
343
+ return (_euler_ancestral_step(_s, _g, model_output, timestep, sample),)
344
+
345
+ scheduler.step = stepped
346
+ elif self.sampler_name == "er_sde":
347
+ for offset, attr_name in enumerate(self.attr_names):
348
+ scheduler = getattr(self.pipe, attr_name)
349
+ scheduler._er_sde_old_denoised = None
350
+ scheduler._er_sde_old_denoised_d = None
351
+ generator = torch.Generator(device="cpu").manual_seed(self.seed + offset)
352
+
353
+ def stepped(model_output, timestep, sample, return_dict=True, _s=scheduler, _g=generator, **_kwargs):
354
+ return (_er_sde_step(_s, _g, model_output, timestep, sample),)
355
+
356
+ scheduler.step = stepped
357
+ return self
358
+
359
+ def __exit__(self, *_):
360
+ for attr_name, original in self._originals.items():
361
+ current = getattr(self.pipe, attr_name)
362
+ current.__dict__.pop("set_timesteps", None)
363
+ current.__dict__.pop("step", None)
364
+ setattr(self.pipe, attr_name, original)
365
+ return False
366
+
367
+
368
+ # ----------------------------------------------------------------------------------------------------------------
369
+ # ImageSharpenKJ(rcas, 0.3)
370
+ # ----------------------------------------------------------------------------------------------------------------
371
+ def rcas(video: torch.Tensor, strength: float, chunk: int = 16) -> torch.Tensor:
372
+ """AMD FidelityFX **RCAS** — Robust Contrast Adaptive Sharpening — on `(frames, 3, H, W)` in `[0, 1]`.
373
+
374
+ The FidelityFX kernel, which is what `ImageSharpenKJ`'s `rcas` mode is: a 5-tap cross, a sharpening lobe whose
375
+ strength is limited per pixel so the ring it would create cannot leave `[0, 1]`, and a renormalised blend.
376
+
377
+ lobe = clamp(attenuation * min over channels of max(-min / 4*max, -(1 - max) / 4*(1 - min)), -0.1875, 0)
378
+ out = (center + lobe * (n + s + e + w)) / (1 + 4 * lobe)
379
+
380
+ `lobe` is negative, so the neighbours are subtracted: a high-pass with a headroom-aware gain, which is why it
381
+ sharpens MiniMax-H3's slightly soft VAE output without haloing it. PlagueKind's 0.3 is the strength; the note in
382
+ the workflow calls it "very natural" and that matches — the lobe clamp caps it well below a visible ring.
383
+
384
+ Batched over `chunk` frames at a time rather than ComfyUI's one, and written back in place: the clip is already
385
+ resident on the card, but this runs immediately after the denoise loop's allocation peak, and a whole-clip pass at
386
+ the full 1344x768x124 would ask the allocator for ~8 GB of intermediates at exactly the wrong moment.
387
+ """
388
+ if strength <= 0:
389
+ return video
390
+
391
+ frames, _, height, width = video.shape
392
+ strength = float(strength)
393
+ for start in range(0, frames, chunk):
394
+ center = video[start : start + chunk]
395
+ padded = torch.nn.functional.pad(center, (1, 1, 1, 1), mode="reflect")
396
+ north = padded[:, :, 0:height, 1 : width + 1]
397
+ south = padded[:, :, 2 : height + 2, 1 : width + 1]
398
+ west = padded[:, :, 1 : height + 1, 0:width]
399
+ east = padded[:, :, 1 : height + 1, 2 : width + 2]
400
+
401
+ low = torch.minimum(torch.minimum(torch.minimum(torch.minimum(north, south), west), east), center)
402
+ high = torch.maximum(torch.maximum(torch.maximum(torch.maximum(north, south), west), east), center)
403
+
404
+ hit_min = -low / (high * 4.0 + 1e-6)
405
+ hit_max = -(1.0 - high) / ((1.0 - low) * 4.0 + 1e-6)
406
+ lobe = torch.maximum(hit_min, hit_max).amin(dim=1, keepdim=True)
407
+ lobe = (lobe * strength).clamp_(-0.1875, 0.0)
408
+ del low, high, hit_min, hit_max
409
+
410
+ neighbours = north + south + east + west
411
+ center.copy_(((center + lobe * neighbours) / (1.0 + 4.0 * lobe)).clamp_(0.0, 1.0))
412
+ return video
413
+
414
+
415
+ # ----------------------------------------------------------------------------------------------------------------
416
+ # FrameInterpolate(film_net_fp16, multiplier=2)
417
+ # ----------------------------------------------------------------------------------------------------------------
418
+ FILM_REPO = "Comfy-Org/frame_interpolation"
419
+ FILM_FILE = "frame_interpolation/film_net_fp16.safetensors"
420
+
421
+
422
+ def load_film():
423
+ """FILM, off the same checkpoint the workflow names. CPU work; `None` on any failure, and the caller skips."""
424
+ from huggingface_hub import hf_hub_download
425
+ from safetensors.torch import load_file
426
+
427
+ from film_net import FILMNet
428
+
429
+ path = hf_hub_download(FILM_REPO, FILM_FILE)
430
+ model = FILMNet()
431
+ model.load_state_dict(load_file(path))
432
+ return model.eval().to(torch.float16)
433
+
434
+
435
+ @torch.no_grad()
436
+ def interpolate(model, video: torch.Tensor, multiplier: int = 2) -> torch.Tensor:
437
+ """`multiplier`x frame interpolation of `(frames, 3, H, W)` in `[0, 1]`, FILM, on the card.
438
+
439
+ Mirrors ComfyUI's `FrameInterpolate`: one pass per adjacent pair, the flow computed once per pair and reused for
440
+ every intermediate timestep (`forward_multi_timestep`), and the feature pyramid of frame `i + 1` carried over as
441
+ frame `i` of the next pair — which halves the feature extractions. Output length is
442
+ `(frames - 1) * multiplier + 1`, i.e. 24 fps in, `24 * multiplier` fps out.
443
+ """
444
+ frames = video.shape[0]
445
+ if model is None or frames < 2 or multiplier < 2:
446
+ return video
447
+
448
+ dtype = torch.float16
449
+ timesteps = [t / multiplier for t in range(1, multiplier)]
450
+ # float16, not the input's float32: the buffer is the largest allocation of the whole post chain (a 2x pass over
451
+ # 124 frames at 1344x768 is 247 of them) and it happens right after the denoise loop's peak.
452
+ out = torch.empty(((frames - 1) * multiplier + 1, *video.shape[1:]), dtype=dtype, device=video.device)
453
+ out[0] = video[0]
454
+ cursor = 1
455
+
456
+ cache: dict = {}
457
+ for index in range(frames - 1):
458
+ first = video[index : index + 1].to(dtype)
459
+ second = video[index + 1 : index + 2].to(dtype)
460
+ cache["img0"] = cache.pop("next") if "next" in cache else model.extract_features(first)
461
+ cache["img1"] = model.extract_features(second)
462
+ cache["next"] = cache["img1"]
463
+
464
+ middles = model.forward_multi_timestep(first, second, timesteps, cache=cache)
465
+ out[cursor : cursor + len(timesteps)] = middles.to(video.dtype).clamp_(0.0, 1.0)
466
+ cursor += len(timesteps)
467
+ out[cursor] = video[index + 1]
468
+ cursor += 1
469
+
470
+ return out