Tries N candidate seeds with one throwaway denoising step each, scores each with the trained output-space value function, commits to the best-scoring seed for the real full pass. Candidates are scored fully isolated from the scene's own guidance stack (model_function_wrapper chain, context_windows, v2a_grad_scale, identity_overlap -- each torn down and reinstalled around the candidate-scoring window) against the scene's fully-finalized conditioning. Any failure in the value function, schedule, or the isolation cycle itself degrades to a silent no-op, declared in the run report, never a crash. Also hardens _install_context_windows/_install_v2a_scale to roll back partial state on failure (a leaked context_handler key was silently re-enabling windowing for later scenes) and fixes the original non-explore v2a install call site to degrade like its sibling mechanisms instead of aborting the render. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
343 lines
16 KiB
Python
343 lines
16 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_a_failed_wrapper_registration_rolls_back_the_context_handler_key(monkeypatch):
|
|
"""context_handler is set BEFORE create_prepare_sampling_wrapper/
|
|
create_sampler_sample_wrapper run -- core's own calc_cond_batch only checks whether
|
|
that KEY is present, not whether the wrappers it implies were actually registered
|
|
(comfy/samplers.py routes through handler.execute() the moment the key exists). If
|
|
either wrapper-creation call raises, a stray context_handler left behind would
|
|
silently re-enable windowing, with a handler no PREPARE_SAMPLING wrapper backs, for
|
|
every later scene and render on this shared model -- exactly the leak class
|
|
[[project_hook_leak_bug]]/[[project_dit_patch_leak]]/[[project_scene_wrapper_leak]]
|
|
already document. Install must roll the key back itself when this happens, not
|
|
leave it to a caller who has no closure to undo it with (no remove_fn is returned
|
|
on failure)."""
|
|
model, calls = _install_fake_core(monkeypatch)
|
|
import comfy.context_windows as cw
|
|
|
|
def _boom(model):
|
|
raise RuntimeError("core wrapper registration exploded")
|
|
|
|
cw.create_prepare_sampling_wrapper = _boom
|
|
s = samplers.FunPackLTXAVSceneChainSampler()
|
|
remove, latent_len, reason = _install(s, model)
|
|
assert remove is None and latent_len is None
|
|
assert "core wrapper registration exploded" in reason
|
|
# The leak this guards against: context_handler must NOT still be present after a
|
|
# failed install, regardless of what raised.
|
|
assert "context_handler" not in model.model_options
|
|
|
|
|
|
def test_a_failed_second_wrapper_registration_also_rolls_back(monkeypatch):
|
|
"""Same as above but the failure is in the SECOND call (create_sampler_sample_wrapper,
|
|
only reached when freenoise is on) -- the first call already succeeded and must not
|
|
leave a half-registered handler behind either."""
|
|
model, calls = _install_fake_core(monkeypatch)
|
|
import comfy.context_windows as cw
|
|
|
|
def _boom(model):
|
|
raise RuntimeError("sampler-sample wrapper exploded")
|
|
|
|
cw.create_sampler_sample_wrapper = _boom
|
|
s = samplers.FunPackLTXAVSceneChainSampler()
|
|
remove, latent_len, reason = _install(s, model, freenoise=True)
|
|
assert remove is None and latent_len is None
|
|
assert "sampler-sample wrapper exploded" in reason
|
|
assert "context_handler" not in model.model_options
|
|
assert calls["prepare"] == 1 # the first call DID run, and still got rolled back
|
|
|
|
|
|
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_actually_resolves(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. Every name the combo
|
|
accepts must now reach a real core schedule — directly or through the alias map."""
|
|
model, _ = _install_fake_core(monkeypatch)
|
|
s = samplers.FunPackLTXAVSceneChainSampler()
|
|
offered = s.INPUT_TYPES()["optional"]["context_window_schedule"][0]
|
|
known = set(sys.modules["comfy.context_windows"].CONTEXT_MAPPING)
|
|
assert known <= set(offered) # nothing core supports is missing from the knob
|
|
for name in offered:
|
|
model, _ = _install_fake_core(monkeypatch)
|
|
assert _install(s, model, schedule=name)[0] is not None, name
|
|
|
|
|
|
def test_the_legacy_names_stay_in_the_combo_list_not_only_in_the_alias_map():
|
|
"""ComfyUI validates combo values at QUEUE time, before the node runs. Dropping the old
|
|
spellings from the list would reject a saved project outright and the alias would never
|
|
get a chance — which is a worse failure than the one being fixed."""
|
|
offered = samplers.FunPackLTXAVSceneChainSampler().INPUT_TYPES()[
|
|
"optional"]["context_window_schedule"][0]
|
|
for legacy in samplers.FunPackLTXAVSceneChainSampler._CTX_SCHEDULE_ALIASES:
|
|
assert legacy in offered, legacy
|
|
|
|
|
|
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
|