Files
olivv-cs-ComfyUI-FunPack/tests/test_context_windows.py
T
digital-garbageandClaude Opus 5 7681c599a0 progress: say which scene and which pass is running; fix context_window_schedule
Three things, all about a run telling you what it is doing.

1. The second pass is now visible. ComfyUI's progress channel carries only
   (value, max), so a chain sampling some scenes twice was one anonymous bar.
   The sampler publishes a live label ("scene 2/3 · pass 2 of 2") through the
   new run_phase module; /api/progress hands it to the editor, which appends it
   to the readout, and pass 2 also announces itself on the console the moment it
   starts rather than only in the after-the-fact run report.

   run_phase keeps its state on `sys`, not in module globals: the writer reaches
   it package-relative and the reader imports FunPack modules top-level by name,
   so they can hold different module objects and the label would never arrive.

2. The progress bar total now counts both passes. It only ever counted the main
   schedule, so with a second pass on it overflowed during scene 1 and jumped
   backwards at scene 2, whose offset is a multiple of the per-scene stride.

3. context_windows was unusable. The knob offered "uniform_standard" etc.;
   core's names are the other way round ("standard_uniform"), so every schedule
   except "batched" raised ValueError out of the sampler and FAILED THE RENDER.
   Core's names are the choices now, the old spellings are aliased so saved
   projects keep working, and an unknown name is refused with a reason naming
   what core accepts instead of crashing.

   Same call had a second, quieter break: a keyword core's handler doesn't take
   (latent_retain_index_list is absent on some builds, including this Mac's)
   raised TypeError, which was reported as "ComfyUI core too old" — silently
   switching off a feature core fully supports. Extras are dropped now, with a
   note naming the one knob that stops working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:57:21 +03:00

284 lines
12 KiB
Python

"""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.
4. The NAMES are core's, not ours. The knob shipped with readable-but-wrong spellings
("uniform_standard"); core calls them the other way round ("standard_uniform"), so
every schedule except "batched" raised ValueError out of the sampler and failed the
whole render. Core's names are the choices now, the old ones are aliased, and an
unknown one is refused with a reason naming what core does accept.
5. A keyword this core does not take is DROPPED, not fatal. latent_retain_index_list is
absent on some builds; passing it raised TypeError, which was reported as "ComfyUI
core too old" — so a core that fully supports windowing had the feature silently
switched off. Install anyway and name the knob that stops working.
Install returns (remove_fn, latent_len, note) or (None, None, reason): every failure here
is a mismatch with the installed core, and reporting the WRONG one is how (5) hid.
"""
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
# Core's real vocabulary (comfy.context_windows ContextSchedules / ContextFuseMethods),
# and core's real behaviour on anything else: ValueError.
cw.CONTEXT_MAPPING = dict.fromkeys(
["standard_uniform", "standard_static", "looped_uniform", "batched"], object())
cw.FUSE_MAPPING = dict.fromkeys(["pyramid", "relative", "flat", "overlap-linear"], object())
def _sched(name):
if name not in cw.CONTEXT_MAPPING:
raise ValueError(f"Unknown context_schedule '{name}'.")
return f"sched:{name}"
def _fuse_of(name):
if name not in cw.FUSE_MAPPING:
raise ValueError(f"Unknown fuse_method '{name}'.")
return f"fuse:{name}"
cw.get_matching_context_schedule = _sched
cw.get_matching_fuse_method = _fuse_of
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()
remove, _, reason = _install(s, model)
assert remove is None and "LTXAV context-window support" in reason
# 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_the_handler_rejects_an_argument_outright(monkeypatch):
"""A TypeError we could not see coming from the signature. Still a refusal — but one
that reports the argument mismatch instead of blaming the core version."""
model, calls = _install_fake_core(monkeypatch, accept_latent_retain=False)
s = samplers.FunPackLTXAVSceneChainSampler()
remove, _, reason = _install(s, model)
assert remove is None and "different" in reason
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
# ── the names are core's, not ours ──────────────────────────────────────────
def test_every_schedule_the_node_offers_is_one_core_knows(monkeypatch):
"""The bug in one line: the node offered spellings core has never had, so choosing any
of them raised ValueError out of the sampler and killed the render."""
model, _ = _install_fake_core(monkeypatch)
s = samplers.FunPackLTXAVSceneChainSampler()
offered = s.INPUT_TYPES()["optional"]["context_window_schedule"][0]
assert set(offered) <= set(sys.modules["comfy.context_windows"].CONTEXT_MAPPING)
for name in offered:
model, _ = _install_fake_core(monkeypatch)
assert _install(s, model, schedule=name)[0] is not None, name
def test_the_old_reversed_spellings_still_resolve(monkeypatch):
"""A project saved before the names were corrected must keep generating, not fail."""
s = samplers.FunPackLTXAVSceneChainSampler()
for old, new in (("uniform_standard", "standard_uniform"),
("static_standard", "standard_static"),
("uniform_looped", "looped_uniform")):
model, _ = _install_fake_core(monkeypatch)
remove, _, reason = _install(s, model, schedule=old)
assert remove is not None, (old, reason)
assert model.model_options["context_handler"].kwargs["context_schedule"] == f"sched:{new}"
def test_an_unknown_schedule_is_refused_with_what_core_accepts(monkeypatch):
model, calls = _install_fake_core(monkeypatch)
s = samplers.FunPackLTXAVSceneChainSampler()
remove, _, reason = _install(s, model, schedule="nonsense")
assert remove is None
assert "nonsense" in reason and "standard_uniform" in reason
# A refusal must not half-configure the shared model.
assert "context_handler" not in model.model_options
assert calls["prepare"] == 0
def test_an_unknown_fuse_method_is_refused_with_what_core_accepts(monkeypatch):
model, _ = _install_fake_core(monkeypatch)
s = samplers.FunPackLTXAVSceneChainSampler()
remove, _, reason = _install(s, model, fuse="wobble")
assert remove is None
assert "wobble" in reason and "overlap-linear" in reason
# ── a keyword this core does not take ───────────────────────────────────────
class _NoLatentRetainHandler:
"""The shape of the build that reported itself as 'core too old': core's handler minus
latent_retain_index_list, declared in the signature so it can be seen before calling."""
def __init__(self, context_schedule, fuse_method, context_length=1, context_overlap=0,
context_stride=1, closed_loop=False, dim=0, freenoise=False,
cond_retain_index_list=(), split_conds_to_windows=False):
self.kwargs = dict(context_schedule=context_schedule, context_length=context_length,
cond_retain_index_list=cond_retain_index_list)
def test_an_unsupported_keyword_is_dropped_and_windowing_still_runs(monkeypatch):
model, calls = _install_fake_core(monkeypatch)
sys.modules["comfy.context_windows"].IndexListContextHandler = _NoLatentRetainHandler
s = samplers.FunPackLTXAVSceneChainSampler()
remove, latent_len, note = _install(s, model, retain_first=True)
assert remove is not None and latent_len == 19
assert calls["prepare"] == 1
# The one knob that really does stop working is named, not swallowed.
assert "latent_retain_index_list" in note
remove()
def test_no_note_when_retain_first_is_off(monkeypatch):
"""Nothing was lost, so nothing to report — a note here would be noise on every scene."""
model, _ = _install_fake_core(monkeypatch)
sys.modules["comfy.context_windows"].IndexListContextHandler = _NoLatentRetainHandler
s = samplers.FunPackLTXAVSceneChainSampler()
assert _install(s, model, retain_first=False)[2] is None