Merge latest upstream Long Videos
This commit is contained in:
@@ -0,0 +1,739 @@
|
||||
# H3-LongVideos -- https://github.com/Smite79/MiniMax-H3-LongVideos
|
||||
# Copyright (c) 2026 Smite79. All rights reserved.
|
||||
# Redistribution, in whole or in part, requires written permission.
|
||||
# This notice may not be removed or altered. See LICENSE.
|
||||
"""Sampling, decoding, resizing, memory handling, and frame assembly."""
|
||||
|
||||
import math
|
||||
|
||||
import torch
|
||||
import nodes
|
||||
import comfy.utils
|
||||
import comfy.sample
|
||||
import comfy.samplers
|
||||
import comfy.nested_tensor
|
||||
import comfy.model_management as mm
|
||||
import latent_preview
|
||||
|
||||
|
||||
class FrameAccumulator:
|
||||
"""Build the final frame tensor once, retaining overflow only when necessary."""
|
||||
|
||||
def __init__(self, capacity, dtype, store_on_cpu):
|
||||
self.capacity = int(capacity)
|
||||
self.dtype = dtype
|
||||
self.store_on_cpu = bool(store_on_cpu)
|
||||
self.tensor = None
|
||||
self.used = 0
|
||||
self.overflow = []
|
||||
|
||||
def add(self, frames):
|
||||
count = int(frames.shape[0])
|
||||
if self.tensor is None and count:
|
||||
device = torch.device("cpu") if self.store_on_cpu else frames.device
|
||||
self.tensor = torch.empty(
|
||||
(max(count, self.capacity),) + tuple(frames.shape[1:]),
|
||||
dtype=self.dtype, device=device)
|
||||
if (not self.overflow and self.tensor is not None
|
||||
and self.used + count <= self.tensor.shape[0]):
|
||||
self.tensor[self.used:self.used + count].copy_(frames)
|
||||
self.used += count
|
||||
return
|
||||
self.overflow.append(frames.to("cpu", self.dtype, copy=True)
|
||||
if self.store_on_cpu else frames)
|
||||
|
||||
def release(self):
|
||||
"""Drop every tensor held, now, rather than whenever the collector gets to it.
|
||||
|
||||
On an interrupt the render unwinds through frames the collector tears down in
|
||||
its own order, and a large video buffer freed after the models it was sized
|
||||
against have already gone is a free the allocator cannot explain. Deliberately
|
||||
does NOT empty the cache: that is another CUDA call, and if the context is
|
||||
already in a sticky error state it is one more thing to abort inside."""
|
||||
self.tensor = None
|
||||
self.overflow = []
|
||||
self.used = 0
|
||||
|
||||
def finish(self):
|
||||
if not self.overflow:
|
||||
if self.tensor is None:
|
||||
return torch.cat(self.overflow, dim=0)
|
||||
if self.used == self.tensor.shape[0]:
|
||||
out = self.tensor
|
||||
else:
|
||||
# COMPACT, never a slice. A slice of a larger buffer keeps the WHOLE
|
||||
# buffer's storage alive, which is the retention this class exists to
|
||||
# prevent -- and test_the_chain_is_never_held_twice measures exactly
|
||||
# that, demanding no unused bytes behind the returned tensor.
|
||||
#
|
||||
# There is slack because the capacity is now an upper bound: it can no
|
||||
# longer assume trim_seam drops a frame at every seam, since a shot that
|
||||
# opens on no keyframe keeps its first frame. Over-allocating by at most
|
||||
# one frame per seam and compacting once is the bounded cost. The
|
||||
# alternative -- an exact guess that can be too small -- drops into the
|
||||
# overflow list, which with cleanup_between_shots off holds every shot's
|
||||
# decoded frames live on the GPU until the end of the run.
|
||||
out = torch.empty((self.used,) + tuple(self.tensor.shape[1:]),
|
||||
dtype=self.dtype, device=self.tensor.device)
|
||||
out.copy_(self.tensor[:self.used])
|
||||
self.tensor = None
|
||||
return out
|
||||
|
||||
extra = sum(int(piece.shape[0]) for piece in self.overflow)
|
||||
reference = self.tensor if self.tensor is not None else self.overflow[0]
|
||||
out = torch.empty((self.used + extra,) + tuple(reference.shape[1:]),
|
||||
dtype=self.dtype, device=reference.device)
|
||||
if self.tensor is not None and self.used:
|
||||
out[:self.used].copy_(self.tensor[:self.used])
|
||||
at = self.used
|
||||
while self.overflow:
|
||||
piece = self.overflow.pop(0)
|
||||
count = int(piece.shape[0])
|
||||
out[at:at + count].copy_(piece)
|
||||
at += count
|
||||
self.tensor = None
|
||||
return out
|
||||
|
||||
|
||||
H3_FPS = 24 # H3 renders 24 fps, always
|
||||
|
||||
|
||||
AUDIO_LATENT_FPS = 40 # audio latent frames per second
|
||||
|
||||
|
||||
AUTO_TILE_T = 8 # temporal chunk for a tiled decode
|
||||
|
||||
|
||||
MAX_FRAMES = 362 # H3's own ceiling (~15s)
|
||||
|
||||
|
||||
CANVAS_MULTIPLE = 32
|
||||
|
||||
|
||||
REF_IMAGE_SHORT_EDGE = 2048
|
||||
|
||||
|
||||
def align_frame_count(n):
|
||||
"""Up to the next valid H3 frame count. The grid is 17k+5."""
|
||||
n = max(5, int(n))
|
||||
while n % 17 != 5:
|
||||
n += 1
|
||||
return min(n, MAX_FRAMES)
|
||||
|
||||
|
||||
def video_latent_t(fc):
|
||||
return 2 if fc <= 5 else ((fc - 5) // 17) * 5 + 2
|
||||
|
||||
|
||||
def temporal_shape(length, fps=H3_FPS):
|
||||
"""(frame count, video latent frames, audio latent frames) for a shot.
|
||||
|
||||
`fps` is accepted but deliberately IGNORED: the audio latent has to line up
|
||||
with 24 fps video or the shot's sound is stretched against its picture."""
|
||||
fc = align_frame_count(length)
|
||||
return fc, video_latent_t(fc), round(fc / H3_FPS * AUDIO_LATENT_FPS)
|
||||
|
||||
|
||||
def ref_image_canvas(w, h, gen_w, gen_h, mode="match"):
|
||||
"""Pure: the (width, height) a reference image is encoded at.
|
||||
|
||||
'match' scales it (DOWN only, aspect kept) to the generation's pixel area, so a
|
||||
reference costs about as much as one frame of the shot. 'max' goes to the
|
||||
reference pipeline's 2048 short edge for the best identity fidelity, which on a
|
||||
long chain is several times slower because the rows are re-attended every step
|
||||
of every shot. Never upscales: a small reference stays small."""
|
||||
w, h = max(1, int(w)), max(1, int(h))
|
||||
if mode == "max":
|
||||
scale = min(1.0, REF_IMAGE_SHORT_EDGE / min(w, h))
|
||||
else:
|
||||
scale = min(1.0, math.sqrt((int(gen_w) * int(gen_h)) / float(w * h)))
|
||||
snap = lambda v: max(CANVAS_MULTIPLE, round(v * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
|
||||
return snap(w), snap(h)
|
||||
|
||||
|
||||
def _resize(image, width, height, crop):
|
||||
s = image[..., :3].movedim(-1, 1)
|
||||
s = comfy.utils.common_upscale(s, width, height, "lanczos", crop)
|
||||
return s.movedim(1, -1)
|
||||
|
||||
|
||||
def _empty_av_latent(width, height, length, fps, batch_size=1):
|
||||
fc, lt, at = temporal_shape(length, fps)
|
||||
video = torch.zeros([batch_size, 24, lt, height // 16, width // 16], device=mm.intermediate_device())
|
||||
audio = torch.zeros([batch_size, 32, 2, at], device=mm.intermediate_device())
|
||||
return {"samples": comfy.nested_tensor.NestedTensor((video, audio))}, fc
|
||||
|
||||
|
||||
def _auto_tile_t(n_latent_frames, requested=None):
|
||||
"""Temporal tile for a tiled decode. An explicit value wins.
|
||||
|
||||
The decode_tile_frames widget is gone, so this is where the value comes from
|
||||
now. It has to come from somewhere: ComfyUI's decode_tiled_3d defaults tile_t
|
||||
to 999, i.e. SPATIAL tiles only, and expanding the whole clip's time axis at
|
||||
once is the single largest allocation in a run. A "tiled" decode that keeps the
|
||||
full temporal extent barely lowers the peak, so the OOM retry that switches
|
||||
tiling on was, without this, retrying with almost the same footprint."""
|
||||
if requested:
|
||||
return int(requested)
|
||||
n = int(n_latent_frames or 0)
|
||||
return AUTO_TILE_T if n > AUTO_TILE_T else None
|
||||
|
||||
|
||||
def _decode_video(vae, out_latent, tiled, free_first=None, tile_t=None, tile_xy=None,
|
||||
keep=()):
|
||||
"""Decode the video latent.
|
||||
|
||||
`free_first` is the diffusion model: sampling is finished, and the video VAE
|
||||
needs the room for THIS decode -- the free runs immediately before it, not to
|
||||
make room for the next shot. On a card where the DiT is most of the VRAM, the
|
||||
decode does not fit until it goes.
|
||||
|
||||
`keep` is what must NOT be evicted on the way. It was `keep_loaded=[]`, which
|
||||
unloaded every resident model -- including the video VAE, which ComfyUI then
|
||||
reloaded three lines later to run the decode. An evict-and-reload of the thing
|
||||
about to be used, once per shot, on every card. Peak VRAM is identical either
|
||||
way, since the VAE has to be resident to decode; the round trip was pure cost.
|
||||
|
||||
memory_required is ASKED FOR HONESTLY, which it was not. It was 1e30, and
|
||||
free_memory computes `memory_to_free = memory_required - get_free_memory(device)`
|
||||
(model_management.py:887), so 1e30 means "unload everything not in keep_loaded",
|
||||
every shot, in full -- skipping partially_unload entirely.
|
||||
|
||||
What that evicts is the DiT, three lines before the next shot needs it again. On
|
||||
a machine whose RAM is already full of finished frames there is nowhere for it to
|
||||
go but disk, so the reload is a read from the drive, once per shot. Reported as
|
||||
thrashing that slows the preload, and it is exactly that: the same weights being
|
||||
read back at every boundary.
|
||||
|
||||
The VAE knows what its own decode costs -- ComfyUI sizes it with
|
||||
memory_used_decode and uses that number everywhere else. Asked for that instead,
|
||||
a card with headroom frees NOTHING and the DiT simply stays. A card without
|
||||
headroom frees what it needs and no more, which is what partially_unload is for.
|
||||
1e30 remains the fallback for a VAE that cannot estimate itself."""
|
||||
latent = out_latent["samples"]
|
||||
if latent.is_nested:
|
||||
latent = latent.unbind()[0]
|
||||
if free_first is not None:
|
||||
try:
|
||||
mm.free_memory(_decode_headroom(vae, latent), mm.get_torch_device(),
|
||||
keep_loaded=_resident(keep or (vae,)))
|
||||
except Exception:
|
||||
pass
|
||||
# A VAE THAT ALREADY TILES DOES NOT NEED TO BE ASKED TO, AND ASKING COSTS 3x.
|
||||
#
|
||||
# MiniMaxH3VideoVAE.decode_tiled is, in full:
|
||||
#
|
||||
# def decode_tiled(self, z, **kwargs):
|
||||
# return self.decode(z)
|
||||
#
|
||||
# Every tile_t/overlap_t/tile_x/tile_y this function computes is discarded, so
|
||||
# the tiling the widget promises is not happening here -- the model tiles
|
||||
# internally either way (256px spatial, 17-frame temporal), which is why
|
||||
# comfy/sd.py sets handles_tiling on it.
|
||||
#
|
||||
# What the detour costs is the OUTPUT BUFFER. comfy's VAE.decode preallocates
|
||||
# ONE result at vae_output_dtype and hands it to the model as output_buffer=,
|
||||
# and MiniMaxH3VideoVAE.decode_temporal writes finalized chunks straight into
|
||||
# it. Going through decode_tiled instead reaches _decode_tiled_owned, which
|
||||
# calls the model with output_buffer=None -- so decode_temporal allocates its
|
||||
# own at torch.float32 -- and then makes an fp16 `copy=True` of that. Two
|
||||
# buffers, the larger of them at double width:
|
||||
#
|
||||
# tiled : fp32 2.60GB + fp16 copy 1.30GB = 3.90GB per shot
|
||||
# decode: one preallocated fp16 = 1.30GB per shot
|
||||
#
|
||||
# at 362 frames of 1056x608. Every shot, on the node's own default.
|
||||
#
|
||||
# So: when the VAE owns its tiling AND can be written into, the un-tiled call IS
|
||||
# the tiled one, minus the copies. Anything else keeps the old path -- this is a
|
||||
# detour around a detour, not a claim that tiling is useless.
|
||||
_owns_tiling = bool(getattr(vae, "handles_tiling", False) and getattr(
|
||||
getattr(vae, "first_stage_model", None), "comfy_has_chunked_io", False))
|
||||
if tiled and _owns_tiling:
|
||||
imgs = vae.decode(latent)
|
||||
elif tiled:
|
||||
# Temporal + spatial tiling. Without tile_t the VAE expands the WHOLE latent
|
||||
# clip at once, which on a 243-frame 1344x768 shot is the single largest
|
||||
# allocation in the run -- and on an unpruned checkpoint that is already
|
||||
# streaming, it is what tips the card over. Decoding in temporal chunks
|
||||
# trades a little speed for a much lower peak; None keeps ComfyUI's defaults.
|
||||
args = {}
|
||||
tile_t = _auto_tile_t(latent.shape[2] if latent.ndim >= 5 else 0, tile_t)
|
||||
if tile_t:
|
||||
args["tile_t"] = int(tile_t)
|
||||
args["overlap_t"] = max(1, int(tile_t) // 8)
|
||||
if tile_xy:
|
||||
args["tile_x"] = int(tile_xy)
|
||||
args["tile_y"] = int(tile_xy)
|
||||
try:
|
||||
imgs = vae.decode_tiled(latent, **args) if args else vae.decode_tiled(latent)
|
||||
except TypeError:
|
||||
imgs = vae.decode_tiled(latent) # older signature without tile_t
|
||||
else:
|
||||
imgs = vae.decode(latent)
|
||||
if len(imgs.shape) == 5:
|
||||
imgs = imgs.reshape(-1, imgs.shape[-3], imgs.shape[-2], imgs.shape[-1])
|
||||
return imgs
|
||||
|
||||
|
||||
def _decode_audio(audio_vae, out_latent):
|
||||
latent = out_latent["samples"]
|
||||
if latent.is_nested:
|
||||
latent = latent.unbind()[-1]
|
||||
audio = audio_vae.decode(latent).movedim(-1, 1)
|
||||
std = torch.std(audio, dim=[1, 2], keepdim=True) * 5.0
|
||||
std[std < 1.0] = 1.0
|
||||
audio = audio / std
|
||||
sr = getattr(audio_vae, "audio_sample_rate_output", getattr(audio_vae, "audio_sample_rate", 44100))
|
||||
return {"waveform": audio, "sample_rate": sr}
|
||||
|
||||
|
||||
def _is_oom(e):
|
||||
return isinstance(e, torch.cuda.OutOfMemoryError) or "out of memory" in str(e).lower()
|
||||
|
||||
|
||||
def _deep_cleanup():
|
||||
"""Release cached VRAM between shots so a long chain does not accumulate and OOM.
|
||||
|
||||
It unloads NOTHING. soft_empty_cache(force) ignores `force` in current ComfyUI
|
||||
(model_management.py:2050) -- the body only reaches empty_cache() and
|
||||
ipc_collect() -- so this drops cached blocks, not models. The `True` is kept
|
||||
only for older builds that read it; the older comment here claimed this took an
|
||||
unload_all_models path, and it does not."""
|
||||
try:
|
||||
mm.soft_empty_cache(True)
|
||||
except TypeError:
|
||||
mm.soft_empty_cache()
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.ipc_collect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
DECODE_HEADROOM = 1.25 # over ComfyUI's own estimate, for working allocations
|
||||
|
||||
|
||||
SAMPLE_HEADROOM = 1.35 # likewise for sampling, which is the longer stretch
|
||||
|
||||
|
||||
def _decode_headroom(vae, latent):
|
||||
"""VRAM this decode actually needs, by the VAE's own estimate. 1e30 if unknown.
|
||||
|
||||
ComfyUI sizes every VAE with memory_used_decode and uses that number itself, so
|
||||
it is the honest figure to hand free_memory. The alternative -- and what was here
|
||||
-- is 1e30, which means "unload everything" and evicts the DiT before every
|
||||
decode, three lines before the next shot reloads it.
|
||||
|
||||
1e30 on failure rather than 0: a bad estimate that frees too little turns a slow
|
||||
render into an OOM, and a wrong guess should fall back to the behaviour that has
|
||||
been running, not to no freeing at all."""
|
||||
try:
|
||||
dtype = getattr(vae, "vae_dtype", None) or latent.dtype
|
||||
need = float(vae.memory_used_decode(tuple(latent.shape), dtype))
|
||||
if need > 0:
|
||||
return need * DECODE_HEADROOM
|
||||
except Exception:
|
||||
pass
|
||||
return 1e30
|
||||
|
||||
|
||||
def _resident(models):
|
||||
"""The LoadedModel entries ComfyUI currently holds for `models`.
|
||||
|
||||
That is the form free_memory's keep_loaded wants: it compares against the
|
||||
entries in current_loaded_models, not against the ModelPatcher objects a node
|
||||
is holding. Anything not matched is simply not kept, so a model that is not
|
||||
resident costs nothing here."""
|
||||
out = []
|
||||
for lm in list(getattr(mm, "current_loaded_models", [])):
|
||||
for m in models or ():
|
||||
if m is None:
|
||||
continue
|
||||
try:
|
||||
if lm.model is m or getattr(lm, "model", None) is getattr(m, "model", None):
|
||||
if lm not in out:
|
||||
out.append(lm)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _image_out_dtype():
|
||||
"""The dtype ComfyUI itself hands between nodes on THIS install.
|
||||
|
||||
The join used to end in a hard-coded .float(), commented "back to what every
|
||||
downstream node expects". That was true when it was written and is not a
|
||||
constant: ComfyUI has --fp16-intermediates, and on an install running it the
|
||||
VAE's own decode already returns fp16 -- VAE.vae_output_dtype() IS
|
||||
model_management.intermediate_dtype() (comfy/sd.py) -- as do EmptyLatentImage
|
||||
and the rest of nodes.py. So on that install the node was taking frames the
|
||||
VAE handed it in fp16, widening them to fp32 nothing had asked for, and
|
||||
handing them to nodes whose own convention is fp16.
|
||||
|
||||
It is the largest thing this node holds, so the widening is not free: the
|
||||
2580-frame chain costed at the join is 9.3GB as fp16 and 18.5GB as fp32,
|
||||
against 44.6GB of staged weights on a 62GB machine -- which is the difference
|
||||
between the render finishing and the OOM killer taking the server. Reported as
|
||||
exactly that, twice.
|
||||
|
||||
Asked, not assumed, and never widened: whatever ComfyUI says it wants between
|
||||
nodes is what the chain is built in. An install with the flag off is told
|
||||
float32 and gets float32, byte for byte what it got before. Older builds have
|
||||
no intermediate_dtype at all, so the fallback is the old constant."""
|
||||
try:
|
||||
return mm.intermediate_dtype()
|
||||
except Exception:
|
||||
return torch.float32
|
||||
|
||||
|
||||
def _evict_all_but(keep_model, latent=None):
|
||||
"""Unload every model EXCEPT the diffusion model from the GPU.
|
||||
|
||||
This is the fix for VRAM ratcheting across a long chain. soft_empty_cache()
|
||||
only drops the CUDA allocator's cached blocks -- it does NOT unload models, so
|
||||
ComfyUI keeps the Qwen3-VL text encoder (~14.6GB) and both VAEs resident in
|
||||
current_loaded_models alongside the DiT. Each shot re-encodes the prompt
|
||||
(text encoder), encodes the handoff keyframe (video VAE), then samples (DiT),
|
||||
so all three compete for the card.
|
||||
|
||||
ComfyUI does free ahead of each load -- load_models_gpu() calls free_memory()
|
||||
for what it is about to need (model_management.py:975), so the weight path is
|
||||
not purely reactive. What it cannot size for is a long chain's ACTIVATIONS on
|
||||
a card where the DiT is most of the VRAM. Freeing explicitly, right after
|
||||
conditioning is built and before sampling, keeps only what the sampler needs.
|
||||
|
||||
ASKED FOR HONESTLY, and this is the expensive one. free_memory computes
|
||||
`memory_to_free = memory_required - get_free_memory(device)`, so 1e30 meant
|
||||
"unload everything but the DiT" on every shot, unconditionally -- on a 48GB card
|
||||
with room for all of it as readily as on a 16GB one. What it unloads is the
|
||||
~14.6GB text encoder and both VAEs, and the next shot re-encodes the prompt and
|
||||
the handoff keyframe, so all three come straight back. On a machine whose RAM is
|
||||
already full of finished frames they come back from DISK, once per shot, which is
|
||||
the thrashing this was reported as.
|
||||
|
||||
The DiT can size its own activations -- memory_required(shape) is what ComfyUI
|
||||
itself calls before a load -- so ask for that. A card with room frees nothing and
|
||||
keeps the encoder resident; a card without frees exactly as much as it must.
|
||||
1e30 stays the fallback, because a bad estimate that frees too little turns a
|
||||
slow render into an OOM."""
|
||||
need = 1e30
|
||||
try:
|
||||
if latent is not None:
|
||||
shape = latent["samples"].shape if isinstance(latent, dict) else latent.shape
|
||||
need = float(keep_model.model.memory_required(tuple(shape))) * SAMPLE_HEADROOM
|
||||
if not (need > 0):
|
||||
need = 1e30
|
||||
except Exception:
|
||||
need = 1e30
|
||||
try:
|
||||
mm.free_memory(need, mm.get_torch_device(),
|
||||
keep_loaded=_resident([keep_model]))
|
||||
except Exception:
|
||||
try:
|
||||
mm.soft_empty_cache(True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _sample_on_sigmas(model, seed, cfg, sampler_name, positive, negative, latent, sigmas):
|
||||
"""common_ksampler, driven by an EXTERNAL sigma schedule.
|
||||
|
||||
common_ksampler derives its sigmas from (sampler_name, scheduler, steps, denoise)
|
||||
and takes no schedule argument, so a schedule computed anywhere else cannot
|
||||
reach it. Under PDD that is fatal rather than merely inconvenient: the heads
|
||||
accept only their nine trained boundaries, and re-deriving the grid from
|
||||
widgets means hitting it by coincidence and losing it again the moment a step
|
||||
count changes.
|
||||
|
||||
Mirrors nodes.common_ksampler's noise / mask / callback handling exactly -- the
|
||||
only substitution is comfy.sample.sample_custom for comfy.sample.sample."""
|
||||
latent_image = latent["samples"]
|
||||
latent_image = comfy.sample.fix_empty_latent_channels(
|
||||
model, latent_image,
|
||||
latent.get("downscale_ratio_spacial", None),
|
||||
latent.get("downscale_ratio_temporal", None))
|
||||
noise = comfy.sample.prepare_noise(latent_image, seed, latent.get("batch_index"))
|
||||
# `steps` here only sizes the progress bar -- the schedule is `sigmas`, whose
|
||||
# step count is one less than its length (the trailing 0.0 is an endpoint).
|
||||
callback = latent_preview.prepare_callback(model, max(len(sigmas) - 1, 1))
|
||||
samples = comfy.sample.sample_custom(
|
||||
model, noise, cfg, comfy.samplers.sampler_object(sampler_name), sigmas,
|
||||
positive, negative, latent_image,
|
||||
noise_mask=latent.get("noise_mask"), callback=callback,
|
||||
disable_pbar=not comfy.utils.PROGRESS_BAR_ENABLED, seed=seed)
|
||||
out = latent.copy()
|
||||
out.pop("downscale_ratio_spacial", None)
|
||||
out.pop("downscale_ratio_temporal", None)
|
||||
out["samples"] = samples
|
||||
return out
|
||||
|
||||
|
||||
RESIZE_CHUNK = 32
|
||||
|
||||
|
||||
def _stream_chunks(total):
|
||||
"""A collector that writes upscaled chunks into ONE destination as they land.
|
||||
|
||||
Both chunk loops in _upscale_frames used `out.append(...)` then
|
||||
`frames = torch.cat(out, dim=0)`. That is the shape the finished-chain join was
|
||||
rebuilt to stop, at a LARGER size: the list holds the whole upscaled chain and
|
||||
the cat allocates a second one, both live at the cat, and `out` is a local that
|
||||
is never cleared -- so it survives the cat, survives the trailing resize, and is
|
||||
still bound at the return. Meanwhile the CALLER's pre-upscale chain cannot be
|
||||
dropped either, because `part = frames[s:s+batch]` is a view into it.
|
||||
|
||||
At 2580 frames of 1056x608 that is 9.26GB per copy per doubling: 37GB x2 at 2x,
|
||||
and 148GB x2 with the RealESRGAN_x4plus that is sitting in models/upscale_models.
|
||||
Preallocating from the first chunk and copying into it removes exactly one of
|
||||
those two, and drops the list at the same time.
|
||||
|
||||
The destination is sized from the FIRST chunk, so the model's scale factor does
|
||||
not have to be known in advance, and the frame count is the caller's own -- an
|
||||
upscaler changes width and height, never the number of frames."""
|
||||
state = {"dst": None, "at": 0}
|
||||
|
||||
def put(piece):
|
||||
if state["dst"] is None:
|
||||
state["dst"] = torch.empty((int(total),) + tuple(piece.shape[1:]),
|
||||
dtype=piece.dtype, device=piece.device)
|
||||
k = int(piece.shape[0])
|
||||
end = min(state["at"] + k, state["dst"].shape[0])
|
||||
if end > state["at"]:
|
||||
state["dst"][state["at"]:end].copy_(piece[:end - state["at"]])
|
||||
state["at"] = end
|
||||
|
||||
def done():
|
||||
d, at = state["dst"], state["at"]
|
||||
if d is None:
|
||||
return None
|
||||
return d if at == d.shape[0] else d[:at]
|
||||
|
||||
return put, done
|
||||
|
||||
|
||||
def _resize_short_edge(frames, target, method="lanczos", chunk=0):
|
||||
"""Resize a [B,H,W,C] frame batch so its short edge == target (keeping aspect,
|
||||
snapped to /32). Plain high-quality resize -- enlarges, doesn't add detail.
|
||||
|
||||
IN CHUNKS, BECAUSE LANCZOS IS FOUR FULL-LENGTH COPIES. The whole chain went
|
||||
into one common_upscale call, and comfy.utils.lanczos is three successive list
|
||||
comprehensions over every frame at once:
|
||||
|
||||
images = [Image.fromarray(...) for image in samples] # N at source size
|
||||
images = [image.resize(...) for image in images] # N at target size
|
||||
images = [torch.from_numpy(np.array(im).astype(np.float32)/255.) ...]
|
||||
result = torch.stack(images)
|
||||
return result.to(samples.device, samples.dtype)
|
||||
|
||||
A comprehension builds the new list completely before rebinding the name, so at
|
||||
each rebind BOTH are live; then torch.stack allocates a full copy while its list
|
||||
still exists, and .to() allocates the result while the stack still exists. Note
|
||||
the astype(np.float32): the input is fp16 but the two largest transients are at
|
||||
DOUBLE its width. At 2580 frames to a 1080 short edge that peaked around 147GB
|
||||
to produce a 29GB result, and it fires on a DOWNSCALE too.
|
||||
|
||||
Chunked, the peak is the result plus one chunk's worth of that machinery. It is
|
||||
bit-identical: PIL resizes each frame independently, so per-chunk and per-chain
|
||||
give the same pixels. The early return for an already-correct size is kept, so
|
||||
the common no-op case still allocates nothing."""
|
||||
b, h, w, c = frames.shape
|
||||
if min(h, w) == target:
|
||||
return frames
|
||||
if h <= w:
|
||||
nh = target; nw = max(32, int(round(target * w / h / 32) * 32))
|
||||
else:
|
||||
nw = target; nh = max(32, int(round(target * h / w / 32) * 32))
|
||||
step = max(1, int(chunk) or RESIZE_CHUNK)
|
||||
out = torch.empty((b, nh, nw, c), dtype=frames.dtype, device=frames.device)
|
||||
for i in range(0, b, step):
|
||||
part = comfy.utils.common_upscale(
|
||||
frames[i:i + step].movedim(-1, 1), nw, nh, method, "disabled")
|
||||
out[i:i + step].copy_(part.movedim(1, -1))
|
||||
del part
|
||||
return out
|
||||
|
||||
|
||||
def _upscale_frames(frames, mode, model_name, target_short_edge, batch=4):
|
||||
"""Optional post-pass upscale of the finished frames (on CPU).
|
||||
mode 'model' : run a ComfyUI upscale model (Real-ESRGAN/UltraSharp class)
|
||||
via the registered loader+apply nodes, chunked with cleanup
|
||||
so 2000+ frames don't OOM; then fit to target short edge.
|
||||
mode 'rtx' : NVIDIA RTX Video Super Resolution (Tensor Cores; fastest,
|
||||
best quality for video -- needs Nvidia_RTX_Nodes_ComfyUI).
|
||||
mode 'lanczos' : plain high-quality resize to the target short edge.
|
||||
Any failure falls back to lanczos (or the raw frames), so it never breaks a
|
||||
render. Returns (frames, note). NOTE: this SHARPENS/ENLARGES; it does not
|
||||
reconstruct video detail the way a second-model (LTX 2.3) pass does."""
|
||||
if mode == "off" or frames is None or getattr(frames, "shape", [0])[0] == 0:
|
||||
return frames, ""
|
||||
note = ""
|
||||
if mode == "rtx":
|
||||
# NVIDIA RTX Video Super Resolution (Comfy-Org/Nvidia_RTX_Nodes_ComfyUI).
|
||||
# Runs on RTX Tensor Cores -- far faster than ESRGAN-class models and
|
||||
# generally cleaner on video, though like them it enhances/enlarges rather
|
||||
# than reconstructing detail (an LTX 2.3 re-generation does that).
|
||||
try:
|
||||
rtx = (_find_node(["rtx", "video", "super"]) or _find_node(["rtxvideosuperresolution"])
|
||||
or _find_node(["rtx", "upscale"]))
|
||||
if rtx is None:
|
||||
raise RuntimeError("RTX node not installed (Nvidia_RTX_Nodes_ComfyUI)")
|
||||
scale = 2
|
||||
if target_short_edge and int(target_short_edge) > 0:
|
||||
cur = min(frames.shape[1], frames.shape[2])
|
||||
if cur > 0:
|
||||
scale = max(1, min(4, int(round(int(target_short_edge) / cur))))
|
||||
_put, _done = _stream_chunks(frames.shape[0])
|
||||
n = frames.shape[0]
|
||||
step = max(1, int(batch))
|
||||
for st in range(0, n, step):
|
||||
part = frames[st:st + step]
|
||||
res = None
|
||||
for kw in ({"image": part, "scale": scale}, {"images": part, "scale": scale},
|
||||
{"image": part, "scale_factor": scale}, {"image": part}):
|
||||
try:
|
||||
res = _invoke_node(rtx, **kw); break
|
||||
except TypeError:
|
||||
continue
|
||||
if res is None:
|
||||
raise RuntimeError("RTX node signature not recognized")
|
||||
_put(res.detach().to("cpu"))
|
||||
del res, part
|
||||
_deep_cleanup()
|
||||
frames = _done()
|
||||
note = f"RTX Video Super Resolution x{scale}"
|
||||
if target_short_edge and int(target_short_edge) > 0:
|
||||
frames = _resize_short_edge(frames, int(target_short_edge))
|
||||
note += f"; fit to {int(target_short_edge)}px short edge"
|
||||
return frames, note
|
||||
except Exception as e:
|
||||
mode = "model"
|
||||
note = f"RTX upscale unavailable ({e}); fell back to model/lanczos"
|
||||
if mode == "model" and model_name and model_name != "none":
|
||||
try:
|
||||
loader = _find_node(["upscale", "model", "load"]) or _find_node(["loadupscalemodel"])
|
||||
applier = _find_node(["imageupscale", "model"]) or _find_node(["upscaleimageusingmodel"])
|
||||
if loader is None or applier is None:
|
||||
raise RuntimeError("upscale-model nodes not found")
|
||||
up_model = _invoke_node(loader, model_name=model_name)
|
||||
_put, _done = _stream_chunks(frames.shape[0])
|
||||
n = frames.shape[0]
|
||||
for s in range(0, n, max(1, int(batch))):
|
||||
part = frames[s:s + max(1, int(batch))]
|
||||
res = _invoke_node(applier, upscale_model=up_model, image=part)
|
||||
_put(res.detach().to("cpu"))
|
||||
del res, part
|
||||
_deep_cleanup()
|
||||
frames = _done()
|
||||
note = f"upscaled with {model_name}"
|
||||
except Exception as e:
|
||||
mode = "lanczos"
|
||||
note = f"model upscale unavailable ({e}); used lanczos"
|
||||
if target_short_edge and int(target_short_edge) > 0:
|
||||
try:
|
||||
frames = _resize_short_edge(frames, int(target_short_edge))
|
||||
note = (note + "; " if note else "") + f"fit to {int(target_short_edge)}px short edge"
|
||||
except Exception as e:
|
||||
note = (note + "; " if note else "") + f"resize failed ({e})"
|
||||
elif mode == "lanczos" and not note:
|
||||
note = "lanczos selected but no target set -> unchanged"
|
||||
return frames, note
|
||||
|
||||
|
||||
def _find_node(substrings):
|
||||
"""Find a registered node whose key contains all of `substrings` (lowercased)."""
|
||||
maps = getattr(nodes, "NODE_CLASS_MAPPINGS", {}) or {}
|
||||
for k, v in maps.items():
|
||||
kl = k.lower()
|
||||
if all(s in kl for s in substrings):
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _invoke_node(cls, **kwargs):
|
||||
"""Call a registered ComfyUI node (V1 FUNCTION or V3 execute) with kwargs and
|
||||
return its first output. Used to reuse ComfyUI's own upscale-model loader/apply
|
||||
so we don't reimplement spandrel loading or tiled scaling."""
|
||||
inst = cls()
|
||||
fn = None
|
||||
if getattr(cls, "FUNCTION", None) and hasattr(inst, cls.FUNCTION):
|
||||
fn = getattr(inst, cls.FUNCTION)
|
||||
else:
|
||||
for cand in ("execute", "upscale", "load_model", "load"):
|
||||
if hasattr(inst, cand):
|
||||
fn = getattr(inst, cand); break
|
||||
if fn is None:
|
||||
raise RuntimeError("no callable entrypoint")
|
||||
out = fn(**kwargs)
|
||||
out = getattr(out, "result", out)
|
||||
return out[0] if isinstance(out, (tuple, list)) else out
|
||||
|
||||
|
||||
# --- THE GRADE THE CHAIN ADDS TO ITSELF -------------------------------------
|
||||
# Every shot boundary decodes a shot, hands its LAST frame over, and re-encodes that as
|
||||
# the next shot's keyframe. The distill reproduces the keyframe faithfully enough to
|
||||
# inherit whatever is already in it and SYNTHESISES frame 0 rather than copying it, so
|
||||
# its own bias lands on top: S_next = a*S + b, a near 1, b above 0. Linear at best,
|
||||
# geometric at worst, invisible shot to shot. And the VAE hard-clips every decode to
|
||||
# 0..1, which makes the expansion a RATCHET -- headroom spent is not recoverable, so it
|
||||
# shows as crushed blacks and blown highlights rather than merely as more contrast.
|
||||
#
|
||||
# These two are the measurement and the correction. Both work per colour channel,
|
||||
# because the clip is per channel: the VAE un-whitens with ImageNet stds before it
|
||||
# clamps, so the 0..1 rails sit at different distances in each channel and the blue
|
||||
# floor and red ceiling bite first. A single luma number would miss the colour half.
|
||||
LEVEL_POOL = 64 # cells per axis the level statistics are measured on
|
||||
|
||||
|
||||
def frame_levels(img):
|
||||
"""(mean, std) per colour channel for one frame, as 3-vectors, or (None, None).
|
||||
|
||||
Area-pooled to LEVEL_POOL first, so a pre-upscale frame and an upscaled one can be
|
||||
compared: pooling measures the PICTURE's levels rather than its resolution. Measured
|
||||
across a 2x resize, std agrees to 0.28% on picture-like content -- and to only 15%
|
||||
on pure noise, because pooling cannot preserve variance that lives entirely at the
|
||||
pixel scale. Real frames are the former, and whatever residual there is cancels
|
||||
anyway: the caller measures the same pipeline difference separately and subtracts it.
|
||||
|
||||
float32 throughout, deliberately: these frames are fp16 under
|
||||
--fp16-intermediates, and an fp16 mean accumulated over a 1344x768 frame biases
|
||||
badly enough to matter at the sizes being corrected here."""
|
||||
x = img
|
||||
if x.dim() == 4:
|
||||
x = x[0]
|
||||
if x.dim() != 3 or int(x.shape[-1]) < 3:
|
||||
return None, None
|
||||
if int(x.shape[0]) < 2 or int(x.shape[1]) < 2:
|
||||
return None, None
|
||||
x = x[..., :3].float().permute(2, 0, 1).unsqueeze(0)
|
||||
p = torch.nn.functional.adaptive_avg_pool2d(x, LEVEL_POOL)[0].reshape(3, -1)
|
||||
return p.mean(dim=1), p.std(dim=1)
|
||||
|
||||
|
||||
# The per-shot motion envelope lived here, measured so the built footsteps could be
|
||||
# timed off the picture. Nothing is built any more -- see the note at the top of
|
||||
# audio.py -- so there is nothing left to time, and a measurement with no reader is a
|
||||
# measurement that rots. Removed with the synthesiser it served.
|
||||
|
||||
|
||||
def apply_levels(img, gain, offset):
|
||||
"""Rescale a frame's contrast and level about its OWN per-channel mean.
|
||||
|
||||
The pivot is the frame's own mean and never a target. That is the whole reason this
|
||||
can run on any scene: a beat that walks into a darker room keeps its darkness,
|
||||
because nothing here knows or cares what the level is -- only how much the last
|
||||
boundary expanded it. Anchoring to shot 1 instead would cancel every deliberate
|
||||
lighting change in the film, which is the opposite failure.
|
||||
|
||||
Clamped into 0..1 because the next thing that happens to this frame is an 8-bit
|
||||
quantisation (comfy.utils.common_upscale goes through a uint8 PIL round trip even
|
||||
at the same size), so there is no headroom outside the range to borrow from."""
|
||||
x = img.float()
|
||||
c = min(3, int(x.shape[-1]))
|
||||
m = x[..., :c].reshape(-1, c).mean(dim=0)
|
||||
g = gain[:c].to(device=x.device, dtype=x.dtype)
|
||||
o = offset[:c].to(device=x.device, dtype=x.dtype)
|
||||
y = x.clone()
|
||||
y[..., :c] = ((x[..., :c] - m) * g + m + o).clamp(0.0, 1.0)
|
||||
return y.to(img.dtype)
|
||||
Reference in New Issue
Block a user