Context windows on the Chain Sampler + fix upsampler load on ComfyUI v0.29.0

Two things, both driven by upstream ComfyUI moving 7 weeks ahead of the
local checkout (2cdaaf4a 2026-06-06 -> e651b7be 2026-07-28, 263 commits).

1. Segmented detailing crashed on ComfyUI v0.29.0. Upstream f8a3fd9d moved
   the LTX latent upsampler onto DynamicVram and made `operations` a required
   argument of LatentUpsampler.from_config; detailing.py still called it with
   one arg, so the pending live probe would have died before detecting
   anything. Now passes comfy.ops.disable_weight_init (what core's own loader
   does) with a TypeError fallback, since rentals run whatever they pulled.

2. New Chain Sampler option: context_windows. Upstream cd77c551 (CORE-3)
   added context-window sampling with real LTX2/LTXAV support - it unpacks
   the packed AV latent, maps each video window onto its proportional audio
   window, and re-slices denoise masks, keyframe_idxs and
   guide_attention_entries per window, so anchors, mid-scene guides and JoyAI
   memory survive inside a window. Nothing is ported: we build core's handler
   and hang it on the scene's model_options exactly as the stock
   LTXVContextWindows node does. Core picks it up in
   comfy.samplers.calc_cond_batch, which every FunPack sampler already routes
   through, so no sampler-loop change was needed and no graph change either -
   it rides the sampler_inputs bridge into both Editor and Easy Gen.

   Refuses (loudly, once per run, with a scene-report note) on cores without
   BaseModel.map_context_window_to_modalities: the handler class predates
   LTXAV support and would window the packed tensor blindly, which is the
   same packed-vs-unpacked trap that killed DynamicConditioning. Install and
   removal are per-scene under the existing wrapper-hygiene finally, so an
   interrupt cannot leak the handler or its two patcher wrappers into later
   runs.

   Knobs (all exposed, none hardcoded): length, overlap, schedule, blend,
   FreeNoise, and pin-anchor-in-every-window. Off by default. Engages only
   on scenes longer than the window, and says so in the scene report when it
   does not, so a scene that sampled whole never reads as a broken feature.

Verified: 8 new unit tests for the install/remove/capability-gate contract;
full suite at baseline (22 pre-existing cross-module stub failures, no new).
Editor checked live - knobs render in Engine > Chain Sampler > Experimental,
dependents gate on the toggle, and values persist into sampler_inputs.

UNVALIDATED LIVE on a rental.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-07-29 21:49:31 +03:00
co-authored by Claude Opus 5
parent 3948f64033
commit e01af72445
4 changed files with 367 additions and 3 deletions
+10 -1
View File
@@ -169,7 +169,16 @@ def load_latent_upsampler(model_name):
raise ValueError(
f"{model_name} is not an LTX latent upsampler (expected post_upsample_res_blocks keys).")
config = json.loads(metadata["config"])
model = LatentUpsampler.from_config(config).to(
# ComfyUI v0.29.0 (upstream f8a3fd9d) moved the upsampler onto DynamicVram and made
# `operations` a required argument of from_config; older cores build nn.Modules
# directly and reject the kwarg. Rentals run whatever they pulled, so accept both.
try:
import comfy.ops
model = LatentUpsampler.from_config(config, operations=comfy.ops.disable_weight_init)
except TypeError:
model = LatentUpsampler.from_config(config)
model = model.to(
dtype=comfy.model_management.vae_dtype(allowed_dtypes=[torch.bfloat16, torch.float32]))
model.load_state_dict(sd)
model.eval()
+15 -1
View File
@@ -226,6 +226,20 @@
{ name: "detail_denoise", label: "Detail re-noise strength", kind: "float", default: 0.85, min: 0.3, max: 0.99, step: 0.05,
deps: [{ name: "segmented_detailing" }, { name: "detail_mode", value: "repair" }],
hint: "How much noise the crop gets re-noised to before its 3-step refine (0.85 is the official LTX 2.3 recipe's own value). Higher = more freedom to genuinely reconstruct the region (fix bad anatomy), risking drift from the surrounding frame; lower = closer to a plain upscale — looks 'detailed' as interpolation but doesn't actually repair it. If the result looks upscaled but not corrected, raise this. Only used in 'repair' mode." },
{ name: "context_windows", label: "Context windows (long scenes)", kind: "bool", default: false,
hint: "EXPERIMENTAL: denoise a scene that's LONGER than the model's comfortable window as overlapping windows instead of one giant pass — ComfyUI core's own mechanism, audio-aware on LTX (it maps each video window to its audio window and re-slices anchors, guides and JoyAI memory per window). Engages only on scenes longer than the window length below; shorter scenes are untouched and pay nothing. Cost isn't a flat multiple — the total work is about the same, plus the overlap being computed twice (~28% at the defaults). Needs ComfyUI v0.29.0 or newer; on older builds it's skipped with a note in the scene report. UNVALIDATED LIVE." },
{ name: "context_window_length", label: "Window length (frames)", kind: "int", default: 145, min: 9, max: 2049, step: 8, dependsOn: "context_windows",
hint: "Window size in real frames. A scene at or below this length skips windowing entirely, so this doubles as the engage threshold. Keep it at or under the length the model already generates well in one pass — the point is to stay inside that range while the scene as a whole goes past it." },
{ name: "context_window_overlap", label: "Window overlap (frames)", kind: "int", default: 40, min: 0, max: 512, step: 8, dependsOn: "context_windows",
hint: "How many frames consecutive windows share. This is the only thing carrying motion and appearance across a window boundary, and also the only extra compute this costs. Too low shows as a seam or a motion hitch at the boundary; too high pays for frames you already have." },
{ name: "context_window_schedule", label: "Window schedule", kind: "combo", choices: ["uniform_standard", "static_standard", "uniform_looped", "batched"], default: "uniform_standard", dependsOn: "context_windows",
hint: "How windows are laid out each step. 'uniform_standard' (default) shifts the grid between steps so boundaries never bake in — safest. 'static_standard' keeps fixed cut points (cheapest, but a bad boundary stays bad). 'uniform_looped' wraps the end into the start for looping content. 'batched' uses disjoint chunks with no overlap logic (fastest, weakest continuity)." },
{ name: "context_window_fuse", label: "Window blend", kind: "combo", choices: ["pyramid", "relative", "flat", "overlap-linear"], default: "pyramid", dependsOn: "context_windows",
hint: "How overlapping windows are weighted when merged. 'pyramid' (default) fades each window toward its edges so seams go soft. 'flat' averages equally (can smear). Change this if boundaries look ghosted rather than merely misaligned." },
{ name: "context_window_freenoise", label: "FreeNoise blending", kind: "bool", default: true, dependsOn: "context_windows",
hint: "Shuffle rather than redraw the starting noise between windows, so overlapping regions begin from correlated noise. Free (a one-time permutation) and core's own default because it measurably improves how windows blend. Turn off only to A/B whether it's helping." },
{ name: "context_window_retain_first", label: "Pin anchor in every window", kind: "bool", default: false, dependsOn: "context_windows",
hint: "Keep latent frame 0 (the i2v anchor) inside every window instead of just the first. Turn on if later windows drift away from the reference image. Off by default because on a continuation scene frame 0 is the carried tail of the previous scene, not the anchor — pinning it everywhere can make the scene read as static. Turn it off again if motion stalls." },
];
const SAMPLER_KNOB_MAP = Object.fromEntries(SAMPLER_KNOBS.map((k) => [k.name, k]));
@@ -351,7 +365,7 @@
chain_timing: ["frame_overlap", "transition_duration", "use_same_seed"],
chain_guidance: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength", "score_slider", "score_slider_strength", "taste_nearest_prompt", "output_guidance", "output_guidance_strength", "dynashift", "dynashift_strength", "dynashift_threshold"],
chain_decode: ["decode_noise_scale", "decode_timestep", "decode_tile_size"],
chain_experimental: ["plateau_cache", "plateau_cache_threshold", "segmented_detailing", "detail_targets", "detail_strength", "detail_threshold", "detail_max_area", "detail_mode", "detail_denoise", "mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"],
chain_experimental: ["context_windows", "context_window_length", "context_window_overlap", "context_window_schedule", "context_window_fuse", "context_window_freenoise", "context_window_retain_first", "plateau_cache", "plateau_cache_threshold", "segmented_detailing", "detail_targets", "detail_strength", "detail_threshold", "detail_max_area", "detail_mode", "detail_denoise", "mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"],
};
function countChainView(p, id) {
+168 -1
View File
@@ -2482,6 +2482,34 @@ class FunPackLTXAVSceneChainSampler:
"default": "repair",
"tooltip": "'repair' (default): upsample the crop, then re-denoise it through the video model for 3 extra steps — can genuinely fix wrong structure (bad anatomy) but costs real compute (~4x region area x 3 steps). 'sharpen': stop after the upsampler's own forward pass — no video-model calls at all, close to free — good for a region that's blurry/under-resolved but already correctly shaped; it CANNOT fix wrong structure (an extra finger stays an extra finger, just sharper), since a super-resolution net only adds detail consistent with what's already there.",
}),
"context_windows": ("BOOLEAN", {
"default": False,
"tooltip": "EXPERIMENTAL: denoise a scene LONGER than the model's native window as overlapping context windows instead of one giant pass (ComfyUI core's own comfy.context_windows, LTX2 branch — nothing ported). Each step splits the scene into windows of context_window_length frames, denoises each, and fuses the overlaps back together. Core's LTXAV path is genuinely audio-aware: it unpacks the packed AV latent, maps each video window onto its proportional AUDIO window, and re-slices denoise masks, keyframe_idxs and guide_attention_entries per window — so i2v anchors, mid-scene guides and JoyAI memory keep working inside a window. Cost is NOT a flat multiple: total tokens stay roughly the same and windowed attention is cheaper than full-length attention, but the overlap is recomputed (overlap/length extra work, ~28% at the defaults). Engages ONLY when the scene is longer than context_window_length — shorter scenes are untouched and pay nothing. Off by default. UNVALIDATED LIVE.",
}),
"context_window_length": ("INT", {
"default": 145, "min": 9, "max": 2049, "step": 8,
"tooltip": "Window size in REAL frames (must be 8n+1; core rounds down to latent frames). A scene at or below this length skips windowing entirely, so this doubles as the engage threshold. Keep it at or under the length the model actually generates well in one pass — the whole point is to stay inside that range while the scene as a whole goes past it.",
}),
"context_window_overlap": ("INT", {
"default": 40, "min": 0, "max": 512, "step": 8,
"tooltip": "How many real frames consecutive windows share. This is the ONLY thing carrying motion/appearance continuity across a window boundary, and it is also the only extra compute this feature costs (overlap/length = the redundant fraction). Too low and boundaries show as a seam or a motion hitch; too high and you pay for frames you already have.",
}),
"context_window_schedule": (["uniform_standard", "static_standard", "uniform_looped", "batched"], {
"default": "uniform_standard",
"tooltip": "How the windows are laid out across the scene, per step. 'uniform_standard' (default, core's own LTXV default) shifts the window grid between steps so boundaries land in different places each step and never bake in — the safest general choice. 'static_standard' keeps the same fixed cut points every step (cheapest, but a bad boundary stays bad). 'uniform_looped' wraps the last window into the first, for seamless looping content. 'batched' denoises disjoint chunks with no overlap logic (fastest, weakest continuity).",
}),
"context_window_fuse": (["pyramid", "relative", "flat", "overlap-linear"], {
"default": "pyramid",
"tooltip": "Weighting used to blend overlapping windows back together. 'pyramid' (default) fades each window toward its edges, so the middle of a window dominates and seams get soft. 'flat' averages equally (can smear). 'relative' and 'overlap-linear' weight by position within the overlap. Change this if boundaries look soft/ghosted rather than merely misaligned.",
}),
"context_window_freenoise": ("BOOLEAN", {
"default": True,
"tooltip": "Shuffle (rather than redraw) the initial noise between windows so overlapping regions start from correlated noise. Costs nothing — it is a one-time permutation of the starting noise — and is core's default for LTXV because it measurably improves how well windows blend. Turn it off only to A/B whether it is helping.",
}),
"context_window_retain_first": ("BOOLEAN", {
"default": False,
"tooltip": "Pin latent frame 0 (the i2v anchor) into EVERY window, in both the conditioning and the noise latent, instead of only the first window. Helps when later windows drift away from the reference image. Off by default because on a CONTINUATION scene frame 0 is the carried tail of the previous scene, not the anchor — pinning it there re-shows the same content in every window and can read as the scene going static. Turn it on if later windows lose the reference; turn it off if the scene stops moving.",
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
@@ -2537,6 +2565,18 @@ class FunPackLTXAVSceneChainSampler:
raise ValueError("Scene chain latents must have a time dimension at index 2.")
return int(tensor.shape[2])
def _context_scene_latent_frames(self, chunk):
"""Video latent frames in this scene's chunk, or None if it can't be read.
Reported only — core decides for itself whether a scene is long enough to window.
Read off the VIDEO tensor (index 0 of the nested AV latent), while the chunk is
still unpacked 5D; after comfy packs it the time axis is gone.
"""
try:
return self._tensor_frames(self._latent_tensors(chunk)[0])
except Exception:
return None
def _latent_tensors(self, latent):
samples = latent.get("samples")
if self._is_nested(samples):
@@ -4285,6 +4325,79 @@ class FunPackLTXAVSceneChainSampler:
except Exception:
pass
# Wrapper keys used by context windows; core registers them under these names.
_CTX_WRAPPER_KEYS = ("ContextWindows_prepare_sampling", "ContextWindows_sampler_sample")
def _install_context_windows(self, model, length, overlap, schedule, fuse,
freenoise, retain_first):
"""Hand this scene's denoise to ComfyUI core's context-window sampler.
Nothing is ported: core owns the whole mechanism (comfy.context_windows), we only
build its handler and hang it on this scene's model_options, the same way the stock
LTXVContextWindows node does. Core picks it up inside comfy.samplers.calc_cond_batch,
which every FunPack sampler already goes through (they all call model(x, sigma, ...)
on the CFGGuider rather than apply_model directly), so no sampler change is needed.
Returns (remove_fn, latent_len) or None when the running ComfyUI core predates LTXAV
window support. That capability gate is the important part: the handler class itself
has existed for a long time for unpacked video, but the packed AV latent needs
BaseModel.map_context_window_to_modalities (+ resize_cond_for_context_window) to
unpack the AV stream, map each video window onto its audio window and re-slice
keyframe_idxs / guide_attention_entries. Without those, enabling this on LTXAV would
window the packed tensor blindly and quietly wreck audio and guides — the same class
of packed-vs-unpacked mistake that killed DynamicConditioning. Refuse instead.
"""
try:
import comfy.context_windows as _cw
import comfy.patcher_extension as _pe
except ImportError:
return None
base = getattr(model, "model", None)
if not hasattr(base, "map_context_window_to_modalities"):
return None
# Real frames -> latent frames, exactly as core's LTXVContextWindows node does it.
latent_len = max(((int(length) - 1) // 8) + 1, 1)
latent_overlap = max(int(overlap) // 8, 0)
retain = "0" if retain_first else ""
try:
handler = _cw.IndexListContextHandler(
context_schedule=_cw.get_matching_context_schedule(schedule),
fuse_method=_cw.get_matching_fuse_method(fuse),
context_length=latent_len,
context_overlap=latent_overlap,
context_stride=1,
closed_loop=False,
dim=2,
freenoise=bool(freenoise),
cond_retain_index_list=retain,
latent_retain_index_list=retain,
split_conds_to_windows=False,
)
except TypeError:
return None # signature drifted from the one this was written against
prev = model.model_options.get("context_handler")
had_prev = "context_handler" in model.model_options
model.model_options["context_handler"] = handler
_cw.create_prepare_sampling_wrapper(model)
if freenoise:
_cw.create_sampler_sample_wrapper(model)
def _remove():
if had_prev:
model.model_options["context_handler"] = prev
else:
model.model_options.pop("context_handler", None)
for wrapper_type, key in (
(_pe.WrappersMP.PREPARE_SAMPLING, self._CTX_WRAPPER_KEYS[0]),
(_pe.WrappersMP.SAMPLER_SAMPLE, self._CTX_WRAPPER_KEYS[1]),
):
try:
model.remove_wrappers_with_key(wrapper_type, key)
except Exception:
pass
return _remove, latent_len
def _build_plateau_cache_wrapper(self, model, threshold):
"""Plateau step-cache (MixCache/Chorus-family, adapted to LTX2.3's distilled schedule).
@@ -4700,6 +4813,9 @@ class FunPackLTXAVSceneChainSampler:
segmented_detailing=False, detail_targets="hands",
detail_upsampler="None", detail_strength=1.0, detail_threshold=0.35,
detail_max_area=0.35, detail_denoise=0.85, detail_mode="repair",
context_windows=False, context_window_length=145, context_window_overlap=40,
context_window_schedule="uniform_standard", context_window_fuse="pyramid",
context_window_freenoise=True, context_window_retain_first=False,
unique_id=None, prompt=None):
if not isinstance(positive, list) or not positive:
raise ValueError("positive conditioning must contain at least one scene entry.")
@@ -4758,6 +4874,12 @@ class FunPackLTXAVSceneChainSampler:
dynashift=dynashift, dynashift_strength=dynashift_strength,
dynashift_threshold=dynashift_threshold,
plateau_cache=plateau_cache, plateau_cache_threshold=plateau_cache_threshold,
context_windows=context_windows, context_window_length=context_window_length,
context_window_overlap=context_window_overlap,
context_window_schedule=context_window_schedule,
context_window_fuse=context_window_fuse,
context_window_freenoise=context_window_freenoise,
context_window_retain_first=context_window_retain_first,
)
max_scene_count = max(1, int(max_scenes))
@@ -4890,6 +5012,7 @@ class FunPackLTXAVSceneChainSampler:
_identity_overlap_state: dict = {}
_detail_upsampler_model = None # lazy: resolved+loaded at the first detailed scene
_detail_disabled_reason = None # set on resolve/load failure: don't retry per scene
_ctx_unsupported_reported = False # print the "core too old" line once, not per scene
for scene_index, scene_cond in enumerate(scene_conditionings):
scene_positive = [scene_cond]
@@ -5009,7 +5132,39 @@ class FunPackLTXAVSceneChainSampler:
_v2a_handles = []
_identity_overlap_handle = None
_plateau_stats = None
_ctx_remove = None
try:
# Context windows: core-owned, installed first so every FunPack wrapper below
# sits inside the per-window forward rather than around the whole clip.
if context_windows:
_ctx = self._install_context_windows(
model, context_window_length, context_window_overlap,
context_window_schedule, context_window_fuse,
context_window_freenoise, context_window_retain_first)
if _ctx is None:
if not _ctx_unsupported_reported:
print("[FunPackSceneChain] context_windows requested but this ComfyUI "
"build has no LTXAV context-window support (needs the CORE-3 "
"context-windows change, ComfyUI >= v0.29.0) — skipped.")
_ctx_unsupported_reported = True
run_mechanisms.append("context_windows(SKIPPED: ComfyUI core too old)")
else:
_ctx_remove, _ctx_latent_len = _ctx
# Core engages windowing only when the scene is longer than the window,
# and logs that decision itself; say so here too so a scene that silently
# sampled whole doesn't read as the feature being broken.
_scene_latent_len = self._context_scene_latent_frames(chunk)
if _scene_latent_len is not None and _scene_latent_len <= _ctx_latent_len:
run_mechanisms.append(
f"context_windows(inactive: scene {_scene_latent_len} <= window "
f"{_ctx_latent_len} latent frames — raise num_frames_per_scene or "
f"lower context_window_length)")
else:
run_mechanisms.append(
f"context_windows({context_window_schedule},{context_window_fuse},"
f"len={_ctx_latent_len},ovl={max(int(context_window_overlap) // 8, 0)}"
f"{',freenoise' if context_window_freenoise else ''}"
f"{',retain_first' if context_window_retain_first else ''})")
# Innermost wrapper (installed first): caches the raw base-model forward on the
# near-noise plateau so later plateau steps reuse it. All guidance wrappers below
# layer around it and still post-process each step's (cached-or-fresh) prediction.
@@ -5135,6 +5290,8 @@ class FunPackLTXAVSceneChainSampler:
run_mechanisms.append(
f"plateau_cache(skipped {_reused}/{_total} plateau fwd, thr={plateau_cache_threshold})")
finally:
if _ctx_remove is not None:
_ctx_remove()
self._remove_v2a_scale(_v2a_handles)
self._strip_identity_overlap(_identity_overlap_handle)
if model.model_options.get("model_function_wrapper") is not _scene_base_wrapper:
@@ -5456,7 +5613,11 @@ class FunPackLTXAVSceneChainSampler:
output_guidance=False, output_guidance_strength=0.02,
dynashift=False, dynashift_strength=0.3, dynashift_threshold=0.6,
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975,
plateau_cache=False, plateau_cache_threshold=0.975):
plateau_cache=False, plateau_cache_threshold=0.975,
context_windows=False, context_window_length=145,
context_window_overlap=40, context_window_schedule="uniform_standard",
context_window_fuse="pyramid", context_window_freenoise=True,
context_window_retain_first=False):
"""Sample one chain per Studio-packed variant entry (seed + index), persisting each result
(latent + preview + per-entry cond + manifest) under ComfyUI temp for rating in Studio.
Reuses sample() per entry with only the seed changed, so each entry is a clean generation."""
@@ -5510,6 +5671,12 @@ class FunPackLTXAVSceneChainSampler:
dynashift=dynashift, dynashift_strength=dynashift_strength,
dynashift_threshold=dynashift_threshold,
plateau_cache=plateau_cache, plateau_cache_threshold=plateau_cache_threshold,
context_windows=context_windows, context_window_length=context_window_length,
context_window_overlap=context_window_overlap,
context_window_schedule=context_window_schedule,
context_window_fuse=context_window_fuse,
context_window_freenoise=context_window_freenoise,
context_window_retain_first=context_window_retain_first,
unique_id=None, prompt=None,
)
last = out
+174
View File
@@ -0,0 +1,174 @@
"""Context-window install/remove contract on the Scene Chain sampler.
The mechanism itself is ComfyUI core's (comfy.context_windows) — nothing here tests
core's windowing math. What IS ours, and what these cover:
1. The capability gate. Core's handler class predates LTXAV support; only builds that
also have BaseModel.map_context_window_to_modalities can unpack the packed AV latent
and re-slice guides/audio per window. On an older core we must REFUSE, not window a
packed tensor blindly (the DynamicConditioning failure mode).
2. Real-frame -> latent-frame conversion matches core's own LTXVContextWindows node.
3. Install/remove leaves the shared model exactly as found, including on an interrupt
mid-scene — the same guarantee _strip_funpack_scene_wrappers gives the function
wrappers, but for patcher wrappers and the context_handler model_option.
"""
import sys
import types
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
for _name in (
"comfy", "comfy.k_diffusion", "comfy.k_diffusion.sampling",
"comfy.model_sampling", "comfy.nested_tensor", "comfy.sample",
"comfy.samplers", "comfy.utils",
):
sys.modules.setdefault(_name, types.ModuleType(_name))
sys.modules["comfy.nested_tensor"].NestedTensor = object
import samplers # noqa: E402
class _Handler:
def __init__(self, **kwargs):
self.kwargs = kwargs
def _install_fake_core(monkeypatch, ltxav_support=True, accept_latent_retain=True):
"""Stand in for comfy.context_windows + comfy.patcher_extension.
Records what the sampler asks core to do, so the test asserts on the request
rather than on core's behaviour.
"""
calls = {"prepare": 0, "sampler_sample": 0, "removed": []}
cw = types.ModuleType("comfy.context_windows")
def _handler(**kwargs):
if not accept_latent_retain and "latent_retain_index_list" in kwargs:
raise TypeError("unexpected keyword argument 'latent_retain_index_list'")
return _Handler(**kwargs)
cw.IndexListContextHandler = _handler
cw.get_matching_context_schedule = lambda s: f"sched:{s}"
cw.get_matching_fuse_method = lambda f: f"fuse:{f}"
def _prep(model):
calls["prepare"] += 1
def _samp(model):
calls["sampler_sample"] += 1
cw.create_prepare_sampling_wrapper = _prep
cw.create_sampler_sample_wrapper = _samp
pe = types.ModuleType("comfy.patcher_extension")
pe.WrappersMP = types.SimpleNamespace(
PREPARE_SAMPLING="prepare_sampling", SAMPLER_SAMPLE="sampler_sample")
monkeypatch.setitem(sys.modules, "comfy.context_windows", cw)
monkeypatch.setitem(sys.modules, "comfy.patcher_extension", pe)
inner = types.SimpleNamespace()
if ltxav_support:
inner.map_context_window_to_modalities = lambda *a, **k: None
model = types.SimpleNamespace(
model_options={},
model=inner,
remove_wrappers_with_key=lambda t, k: calls["removed"].append((t, k)),
)
return model, calls
def _install(sampler, model, **overrides):
kwargs = dict(length=145, overlap=40, schedule="uniform_standard",
fuse="pyramid", freenoise=True, retain_first=False)
kwargs.update(overrides)
return sampler._install_context_windows(model, **kwargs)
def test_refuses_on_core_without_ltxav_window_support(monkeypatch):
model, calls = _install_fake_core(monkeypatch, ltxav_support=False)
s = samplers.FunPackLTXAVSceneChainSampler()
assert _install(s, model) is None
# Nothing installed: a refusal must not half-configure the shared model.
assert "context_handler" not in model.model_options
assert calls["prepare"] == 0 and calls["sampler_sample"] == 0
def test_refuses_when_handler_signature_drifted(monkeypatch):
model, calls = _install_fake_core(monkeypatch, accept_latent_retain=False)
s = samplers.FunPackLTXAVSceneChainSampler()
assert _install(s, model) is None
assert "context_handler" not in model.model_options
def test_real_frames_convert_to_latent_frames_like_core_node(monkeypatch):
model, _ = _install_fake_core(monkeypatch)
s = samplers.FunPackLTXAVSceneChainSampler()
remove, latent_len = _install(s, model, length=145, overlap=40)
# Core's LTXVContextWindows: ((145 - 1) // 8) + 1 = 19, 40 // 8 = 5.
assert latent_len == 19
handler = model.model_options["context_handler"]
assert handler.kwargs["context_length"] == 19
assert handler.kwargs["context_overlap"] == 5
assert handler.kwargs["dim"] == 2
remove()
def test_retain_first_sets_both_cond_and_latent_retain(monkeypatch):
model, _ = _install_fake_core(monkeypatch)
s = samplers.FunPackLTXAVSceneChainSampler()
remove, _ = _install(s, model, retain_first=True)
handler = model.model_options["context_handler"]
# i2v anchors live in the noise latent, not only in conditioning channels, so both
# lists must be set — cond alone would drop the anchor from later windows.
assert handler.kwargs["cond_retain_index_list"] == "0"
assert handler.kwargs["latent_retain_index_list"] == "0"
remove()
def test_freenoise_off_skips_the_sampler_sample_wrapper(monkeypatch):
model, calls = _install_fake_core(monkeypatch)
s = samplers.FunPackLTXAVSceneChainSampler()
remove, _ = _install(s, model, freenoise=False)
assert calls["prepare"] == 1
assert calls["sampler_sample"] == 0
remove()
def test_remove_restores_model_options_and_drops_both_wrappers(monkeypatch):
model, calls = _install_fake_core(monkeypatch)
s = samplers.FunPackLTXAVSceneChainSampler()
remove, _ = _install(s, model)
assert "context_handler" in model.model_options
remove()
assert "context_handler" not in model.model_options
assert calls["removed"] == [
("prepare_sampling", "ContextWindows_prepare_sampling"),
("sampler_sample", "ContextWindows_sampler_sample"),
]
def test_remove_restores_a_pre_existing_foreign_handler(monkeypatch):
"""A user-placed LTXVContextWindows node upstream must survive our scene."""
model, _ = _install_fake_core(monkeypatch)
foreign = object()
model.model_options["context_handler"] = foreign
s = samplers.FunPackLTXAVSceneChainSampler()
remove, _ = _install(s, model)
assert model.model_options["context_handler"] is not foreign
remove()
assert model.model_options["context_handler"] is foreign
def test_scene_latent_frame_count_reads_the_video_stream():
s = samplers.FunPackLTXAVSceneChainSampler()
chunk = {"samples": torch.zeros(1, 128, 23, 8, 8)}
assert s._context_scene_latent_frames(chunk) == 23
# Unreadable latent reports None rather than raising: this value is only ever used
# for the scene report, never for control flow.
assert s._context_scene_latent_frames({"samples": None}) is None