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>
This commit is contained in:
digital-garbage
2026-07-31 17:57:21 +03:00
co-authored by Claude Opus 5
parent 39533dfde4
commit 7681c599a0
8 changed files with 501 additions and 51 deletions
+17 -1
View File
@@ -658,8 +658,24 @@ def reset_progress() -> None:
def current_progress() -> dict:
"""Sampler step counter, plus what the Chain Sampler says it is doing right now.
ComfyUI's progress channel is numbers only, so a chain that samples several scenes —
some of them twice, with a second pass — is one anonymous bar. The sampler publishes a
short phase label ("scene 2/3 · pass 2 of 2") that rides along here. Absent (an older
sampler, a non-FunPack job) it is simply an empty string and the UI shows the numbers
alone, exactly as before.
"""
_install_progress_hook()
return {"value": _progress["value"], "max": _progress["max"]}
label = ""
try:
# Top-level by name (never a relative import past this package — see
# _ensure_funpack_path). run_phase keeps its state on `sys` precisely so it does not
# matter whether the sampler's copy and this one are the same module object.
label = _funpack_attr("run_phase", "current")().get("label") or ""
except Exception: # noqa: BLE001 — a readout must never break the progress poll
pass
return {"value": _progress["value"], "max": _progress["max"], "label": label}
async def is_running(prompt_id: str) -> bool:
+2 -2
View File
@@ -236,8 +236,8 @@
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_schedule", label: "Window schedule", kind: "combo", choices: ["standard_uniform", "standard_static", "looped_uniform", "batched"], default: "standard_uniform", dependsOn: "context_windows",
hint: "How windows are laid out each step (ComfyUI core's own schedule names). 'standard_uniform' (default) shifts the grid between steps so boundaries never bake in — safest. 'standard_static' keeps fixed cut points (cheapest, but a bad boundary stays bad). 'looped_uniform' 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",
+10 -4
View File
@@ -3211,17 +3211,22 @@
_clearGenTimers();
let pendingStreak = 0;
let transientStreak = 0;
// Faster step-progress poll (sampler current/total steps).
// Faster step-progress poll (sampler current/total steps, plus what it says it's
// doing — "scene 2/3 · pass 2 of 2"). The message is rebuilt from prefix + elapsed
// the same way the slower poll below builds it, rather than regex-stripping the last
// one off the end: a suffix the strip pattern doesn't know about (the phase label)
// would otherwise be re-appended every tick and grow without limit.
progressTimer = setInterval(async () => {
if (_interrupted) return;
try {
const pr = await API.progress();
if (pr && pr.max > 0) {
const head = (state.gen.msg || prefix).replace(/\s*·\s*sampling \d+\/\d+$/, "");
const phase = pr.label ? ` · ${pr.label}` : "";
updateGenProgress({
step: pr.value,
maxStep: pr.max,
msg: `${head} · sampling ${pr.value}/${pr.max}`,
phase: pr.label || "",
msg: `${prefix} ${_elapsed()} · sampling ${pr.value}/${pr.max}${phase}`,
});
}
} catch (_) {}
@@ -3257,7 +3262,8 @@
return;
}
const step = (state.gen.maxStep > 0) ? ` · sampling ${state.gen.step}/${state.gen.maxStep}` : "";
updateGenProgress({ state: s.state, msg: `${prefix} ${_elapsed()}${step}` });
const phase = state.gen.phase ? ` · ${state.gen.phase}` : "";
updateGenProgress({ state: s.state, msg: `${prefix} ${_elapsed()}${step}${phase}` });
}
} catch (e) {
if (_isTransientTunnelError(e)) {
+59
View File
@@ -0,0 +1,59 @@
"""What the sampler is doing RIGHT NOW, as a short label a UI can poll.
ComfyUI's progress channel carries only (value, max) — no text — so a chain that samples
several scenes, some of them twice, reads as one undifferentiated bar: you can watch the
number climb without knowing which scene it belongs to or whether the second pass has
started. The Chain Sampler publishes a label here as it moves through the run, and the
Movie Editor's ``/api/progress`` hands it to the UI alongside the numbers.
**The state lives on `sys`, not in this module's globals, and that is load-bearing.** The
writer (samplers.py) reaches this file as a package-relative import, so it gets
``ComfyUI-FunPack.run_phase``; the reader (movie_editor/backend/bridge.py) imports FunPack
modules top-level by name after putting the root on sys.path, so it would get a SECOND,
separate ``run_phase`` module object — two copies, two sets of globals, and a label that
never arrives. Hanging the dict off ``sys`` (unambiguously one object per process) makes
both import paths share it. Stateless helpers like `conditioning` do not care which copy
they are; anything holding mutable state across that boundary does.
Everything here is BEST-EFFORT and never affects sampling: it is a live readout, not state
anything is derived from. Nothing reads it back to make a decision, so a label left over
from a crashed run is cosmetic — the next run overwrites it and ``clear()`` empties it —
and it is not the kind of cross-request cache that would violate "the UI is the only source
of truth".
"""
import sys
_SYS_KEY = "_funpack_run_phase"
def _state() -> dict:
st = getattr(sys, _SYS_KEY, None)
if not isinstance(st, dict):
st = {"label": "", "seq": 0}
setattr(sys, _SYS_KEY, st)
return st
def set_phase(label: str) -> None:
"""Publish the current phase, e.g. "scene 2/3 · pass 2 of 2"."""
try:
st = _state()
st["label"] = str(label or "")
st["seq"] += 1
except Exception: # noqa: BLE001 — a readout must never break a render
pass
def clear() -> None:
"""Nothing is sampling. Call from a finally, so an interrupt doesn't leave a label up."""
set_phase("")
def current() -> dict:
"""{"label": str, "seq": int}. `seq` only ever increases, so a poller can tell "still
the same phase" from "this phase came round again"."""
try:
st = _state()
return {"label": st["label"], "seq": st["seq"]}
except Exception: # noqa: BLE001
return {"label": "", "seq": 0}
+128 -33
View File
@@ -2501,9 +2501,9 @@ class FunPackLTXAVSceneChainSampler:
"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_schedule": (["standard_uniform", "standard_static", "looped_uniform", "batched"], {
"default": "standard_uniform",
"tooltip": "How the windows are laid out across the scene, per step. These are ComfyUI core's own schedule names (comfy.context_windows). 'standard_uniform' (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. 'standard_static' keeps the same fixed cut points every step (cheapest, but a bad boundary stays bad). 'looped_uniform' wraps the last window into the first, for seamless looping content. 'batched' denoises disjoint chunks with no overlap logic (fastest, weakest continuity). Projects saved with the old reversed spellings (uniform_standard / static_standard / uniform_looped) are still accepted and mapped onto these.",
}),
"context_window_fuse": (["pyramid", "relative", "flat", "overlap-linear"], {
"default": "pyramid",
@@ -2947,6 +2947,23 @@ class FunPackLTXAVSceneChainSampler:
result.pop("noise_mask", None)
return result
def _set_phase(self, label):
"""Publish what is running right now for the editor's progress readout.
The ComfyUI progress channel is (value, max) only, so without this a multi-scene
chain — especially one sampling some scenes twice — is a single anonymous bar.
Best-effort in every direction: a missing module or a failed write must never
affect the render.
"""
try:
try:
from . import run_phase as _rp
except ImportError: # loaded as a top-level module (tests, direct import)
import run_phase as _rp
_rp.set_phase(label)
except Exception: # noqa: BLE001
pass
def _sample_chunk(self, model, sampler, sigmas, seed, cfg, positive, negative, latent,
pbar=None, step_offset=0, alg_guide_tail_frames=0,
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975,
@@ -4553,6 +4570,18 @@ class FunPackLTXAVSceneChainSampler:
# Wrapper keys used by context windows; core registers them under these names.
_CTX_WRAPPER_KEYS = ("ContextWindows_prepare_sampling", "ContextWindows_sampler_sample")
# Core's schedule identifiers put the words the other way round (comfy.context_windows
# ContextSchedules: "standard_uniform", "standard_static", "looped_uniform", "batched").
# This knob shipped with the readable-but-wrong spellings, so every schedule except
# "batched" raised ValueError out of get_matching_context_schedule and failed the whole
# render. The knob now offers core's own names; these aliases keep a project saved with
# the old ones working instead of failing at generation time.
_CTX_SCHEDULE_ALIASES = {
"uniform_standard": "standard_uniform",
"static_standard": "standard_static",
"uniform_looped": "looped_uniform",
}
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.
@@ -4563,8 +4592,12 @@ class FunPackLTXAVSceneChainSampler:
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
Returns (remove_fn, latent_len, None), or (None, None, reason) when it cannot run.
Everything that can go wrong here is a mismatch with the installed core, and every
one of them used to surface as either the wrong explanation or a failed render, so
the reason is carried back and reported per scene rather than inferred.
The capability gate below 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
@@ -4576,30 +4609,63 @@ class FunPackLTXAVSceneChainSampler:
import comfy.context_windows as _cw
import comfy.patcher_extension as _pe
except ImportError:
return None
return None, None, ("this ComfyUI build has no context-window support at all "
"(needs ComfyUI >= v0.29.0)")
base = getattr(model, "model", None)
if not hasattr(base, "map_context_window_to_modalities"):
return None
return None, None, ("this ComfyUI build has no LTXAV context-window support "
"(needs the core context-windows change, ComfyUI >= v0.29.0)")
# 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 ""
# Resolve the names against CORE's vocabulary, not ours, and say what it accepts —
# these raise ValueError, which used to escape and fail the render outright.
_sched_name = self._CTX_SCHEDULE_ALIASES.get(str(schedule), str(schedule))
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
_schedule = _cw.get_matching_context_schedule(_sched_name)
except ValueError:
_known = ", ".join(sorted(getattr(_cw, "CONTEXT_MAPPING", {}) or {}))
return None, None, (f"context_window_schedule={schedule!r} is not a schedule this "
f"ComfyUI knows{f' — it accepts {_known}' if _known else ''}")
try:
_fuse = _cw.get_matching_fuse_method(str(fuse))
except ValueError:
_known = ", ".join(sorted(getattr(_cw, "FUSE_MAPPING", {}) or {}))
return None, None, (f"context_window_fuse={fuse!r} is not a blend this ComfyUI "
f"knows{f' — it accepts {_known}' if _known else ''}")
kwargs = dict(
context_schedule=_schedule,
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,
)
# Core's handler has gained and lost keywords over time (latent_retain_index_list is
# absent on some builds). Passing one it doesn't take raised TypeError and the whole
# feature was then reported as "core too old", which is a different and misleading
# thing. Drop the extras instead, and name the one knob that stops working.
_dropped = []
try:
import inspect as _inspect
_accepted = set(_inspect.signature(_cw.IndexListContextHandler).parameters)
if "kwargs" not in _accepted:
_dropped = [k for k in kwargs if k not in _accepted]
for k in _dropped:
kwargs.pop(k)
except (TypeError, ValueError):
pass
try:
handler = _cw.IndexListContextHandler(**kwargs)
except TypeError as exc:
return None, None, (f"this ComfyUI's context-window handler takes different "
f"arguments than expected ({exc})")
prev = model.model_options.get("context_handler")
had_prev = "context_handler" in model.model_options
model.model_options["context_handler"] = handler
@@ -4621,7 +4687,11 @@ class FunPackLTXAVSceneChainSampler:
except Exception:
pass
return _remove, latent_len
_note = None
if retain_first and "latent_retain_index_list" in _dropped:
_note = ("this ComfyUI's context-window handler has no latent_retain_index_list, "
"so 'pin anchor in every window' applies to conditioning only")
return _remove, latent_len, _note
def _build_plateau_cache_wrapper(self, model, threshold):
"""Plateau step-cache (MixCache/Chorus-family, adapted to LTX2.3's distilled schedule).
@@ -5039,7 +5109,7 @@ class FunPackLTXAVSceneChainSampler:
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_schedule="standard_uniform", context_window_fuse="pyramid",
context_window_freenoise=True, context_window_retain_first=False,
cut_opening_frames=0,
second_pass=False, second_pass_op="none", second_pass_sigmas=None,
@@ -5223,7 +5293,17 @@ class FunPackLTXAVSceneChainSampler:
if first_scene_seed is None:
first_scene_seed = int(seed)
steps_per_scene = max(1, int(len(sigmas)) - 1)
# A second pass samples every scene twice, so its steps have to be in the total or
# the bar overflows on scene 1 and jumps backwards on scene 2 (each scene's offset
# is a multiple of steps_per_scene). The pass-2 schedule is one node input shared by
# every scene, so it can be measured once here — and validated the same way the loop
# will, so an unusable schedule doesn't inflate the total for a pass that gets skipped.
_pass2_steps = 0
if second_pass:
_sp_probe, _ = self._second_pass_schedule(second_pass_sigmas)
if _sp_probe is not None:
_pass2_steps = max(0, int(_sp_probe.numel()) - 1)
steps_per_scene = max(1, (int(len(sigmas)) - 1) + _pass2_steps)
total_sampling_steps = scene_count * steps_per_scene
pbar = None
try:
@@ -5367,19 +5447,21 @@ class FunPackLTXAVSceneChainSampler:
# 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(
_ctx_remove, _ctx_latent_len, _ctx_reason = 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 _ctx_remove is None:
# Every reason here is a mismatch with the installed core, so it is
# the same for every scene — print it once, report it on each.
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.")
print(f"[FunPackSceneChain] context_windows requested but "
f"{_ctx_reason} — skipped.")
_ctx_unsupported_reported = True
run_mechanisms.append("context_windows(SKIPPED: ComfyUI core too old)")
run_mechanisms.append(f"context_windows(SKIPPED: {_ctx_reason})")
else:
_ctx_remove, _ctx_latent_len = _ctx
if _ctx_reason:
run_mechanisms.append(f"context_windows NOTE: {_ctx_reason}")
# 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.
@@ -5568,6 +5650,10 @@ class FunPackLTXAVSceneChainSampler:
_sp_b, _sp_reason = self._second_pass_schedule(second_pass_sigmas)
if _sp_b is None:
run_mechanisms.append(f"second_pass(SKIPPED: {_sp_reason})")
_scene_label = (f"scene {scene_index + 1}/{scene_count}"
if scene_count > 1 else "")
self._set_phase(f"{_scene_label}{' · ' if _scene_label else ''}"
f"{'pass 1 of 2' if _sp_b is not None else 'sampling'}")
_full = self._sample_chunk(
model, sampler, sigmas, scene_seed, cfg, scene_positive,
scene_negative, chunk, **_sample_kwargs)
@@ -5611,6 +5697,12 @@ class FunPackLTXAVSceneChainSampler:
"unpinned and may drift from the reference image.")
_sp_kw = dict(_sample_kwargs)
_sp_kw["step_offset"] = _sample_kwargs["step_offset"] + (int(sigmas.numel()) - 1)
# Announced BEFORE it runs: the after-the-fact run_mechanisms line says
# a second pass happened, which is no help while you are waiting on one.
self._set_phase(f"{_scene_label}{' · ' if _scene_label else ''}pass 2 of 2")
print(f"[FunPack AV] second pass starting"
f"{' on ' + _scene_label if _scene_label else ''}: "
f"{int(_sp_b.numel()) - 1} steps from sigma {float(_sp_b[0].item()):g}")
sampled = self._sample_chunk(
model, sampler, _sp_b, scene_seed + 4242, cfg, scene_positive,
scene_negative, _sp_state, **_sp_kw)
@@ -5628,6 +5720,9 @@ class FunPackLTXAVSceneChainSampler:
run_mechanisms.append(
f"plateau_cache(skipped {_reused}/{_total} plateau fwd, thr={plateau_cache_threshold})")
finally:
# Nothing is sampling once this scene is out of the sampler — including on an
# interrupt, which is exactly when a stale "pass 2 of 2" would linger.
self._set_phase("")
if _ctx_remove is not None:
_ctx_remove()
self._remove_v2a_scale(_v2a_handles)
@@ -5972,7 +6067,7 @@ class FunPackLTXAVSceneChainSampler:
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_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_overlap=40, context_window_schedule="standard_uniform",
context_window_fuse="pyramid", context_window_freenoise=True,
context_window_retain_first=False,
cut_opening_frames=0,
+119 -10
View File
@@ -11,6 +11,18 @@ core's windowing math. What IS ours, and what these cover:
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
@@ -52,8 +64,24 @@ def _install_fake_core(monkeypatch, ltxav_support=True, accept_latent_retain=Tru
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}"
# 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
@@ -93,23 +121,27 @@ def _install(sampler, model, **overrides):
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
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_handler_signature_drifted(monkeypatch):
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()
assert _install(s, model) is None
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)
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"]
@@ -122,7 +154,7 @@ def test_real_frames_convert_to_latent_frames_like_core_node(monkeypatch):
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)
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.
@@ -134,7 +166,7 @@ def test_retain_first_sets_both_cond_and_latent_retain(monkeypatch):
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)
remove, _, _ = _install(s, model, freenoise=False)
assert calls["prepare"] == 1
assert calls["sampler_sample"] == 0
remove()
@@ -143,7 +175,7 @@ def test_freenoise_off_skips_the_sampler_sample_wrapper(monkeypatch):
def test_remove_restores_model_options_and_drops_both_wrappers(monkeypatch):
model, calls = _install_fake_core(monkeypatch)
s = samplers.FunPackLTXAVSceneChainSampler()
remove, _ = _install(s, model)
remove, _, _ = _install(s, model)
assert "context_handler" in model.model_options
remove()
assert "context_handler" not in model.model_options
@@ -159,7 +191,7 @@ def test_remove_restores_a_pre_existing_foreign_handler(monkeypatch):
foreign = object()
model.model_options["context_handler"] = foreign
s = samplers.FunPackLTXAVSceneChainSampler()
remove, _ = _install(s, model)
remove, _, _ = _install(s, model)
assert model.model_options["context_handler"] is not foreign
remove()
assert model.model_options["context_handler"] is foreign
@@ -172,3 +204,80 @@ def test_scene_latent_frame_count_reads_the_video_stream():
# 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
+52
View File
@@ -0,0 +1,52 @@
"""run_phase — the live "what is the sampler doing" label behind the progress readout.
The one invariant that actually breaks in production: the WRITER (samplers.py) reaches this
module as a package-relative import and the READER (movie_editor's bridge) imports FunPack
modules top-level by name, so the two can hold different module objects. State in module
globals would be two separate copies and the label would never arrive. It lives on `sys`
for exactly that reason, and this pins it down.
"""
import importlib.util
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
import run_phase # noqa: E402
def _second_import():
"""A second, independent module object for the same file — what the other import path
produces at runtime."""
spec = importlib.util.spec_from_file_location("other_pkg.run_phase", ROOT / "run_phase.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_both_import_paths_see_the_same_label():
other = _second_import()
run_phase.set_phase("scene 2/3 · pass 2 of 2")
assert other.current()["label"] == "scene 2/3 · pass 2 of 2"
other.clear()
assert run_phase.current()["label"] == ""
def test_seq_only_ever_increases():
"""A poller uses it to tell "still the same phase" from "this phase came round again",
so it must never reset — not even when the label is cleared."""
before = run_phase.current()["seq"]
run_phase.set_phase("a")
run_phase.set_phase("a")
run_phase.clear()
assert run_phase.current()["seq"] == before + 3
def test_a_bad_label_never_raises():
"""This is a readout on the sampling path: it must not be able to fail a render."""
run_phase.set_phase(None)
assert run_phase.current()["label"] == ""
run_phase.set_phase(12)
assert run_phase.current()["label"] == "12"
run_phase.clear()
+114 -1
View File
@@ -2,6 +2,7 @@ import sys
import types
from pathlib import Path
import pytest
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -67,6 +68,10 @@ def fake_sample_custom(model, noise, cfg, sampler, sigmas, positive, negative, l
"positive": positive,
"negative": negative,
"cfg": cfg,
"steps": max(0, int(sigmas.numel()) - 1),
# What the sampler is telling the UI it's doing, AS this call runs — the point of
# the label is that it is live, so reading it afterwards would prove nothing.
"phase": run_phase.current()["label"],
"latent_image": _sample_snapshot(latent_image),
"noise_mask": _sample_snapshot(noise_mask),
})
@@ -90,6 +95,21 @@ comfy_sample_mod = types.ModuleType("comfy.sample")
comfy_samplers_mod = types.ModuleType("comfy.samplers")
comfy_utils_mod = types.ModuleType("comfy.utils")
class FakeProgressBar:
"""Records what the sampler declared as the run's total step count."""
last = None
def __init__(self, total):
self.total = int(total)
self.value = 0
FakeProgressBar.last = self
def update_absolute(self, value, total=None, preview=None):
self.value = int(value)
comfy_utils_mod.ProgressBar = FakeProgressBar
comfy_nested_mod.NestedTensor = FakeNestedTensor
comfy_sample_mod.prepare_noise = fake_prepare_noise
comfy_sample_mod.sample_custom = fake_sample_custom
@@ -111,7 +131,8 @@ sys.modules.setdefault("comfy.sample", comfy_sample_mod)
sys.modules.setdefault("comfy.samplers", comfy_samplers_mod)
sys.modules.setdefault("comfy.utils", comfy_utils_mod)
from samplers import FunPackLTXAVSceneChainSampler
import run_phase # noqa: E402
from samplers import FunPackLTXAVSceneChainSampler # noqa: E402
class FakeVAE:
@@ -738,3 +759,95 @@ def test_plateau_cache_keys_cond_and_uncond_separately():
assert stats["computed"] == 2 # cond + uncond each computed once
assert stats["reused"] == 2 # then each reused once
assert calls["n"] == 2
# ── second pass: the progress bar and the phase readout ─────────────────────
@pytest.fixture
def live_stubs():
"""Force this file's comfy fakes onto whatever module objects are actually live.
The module-level `sys.modules.setdefault` above only wins when this file is imported
FIRST — under the full suite another test module has usually registered bare comfy
stubs already, so the fakes here are silently discarded and every sample()-calling test
in this file fails on the missing attribute. Snapshot/restore rather than leaving them
behind, so the next module's expectations are not the ones broken instead."""
targets = [
(sys.modules["comfy.sample"], "prepare_noise", fake_prepare_noise),
(sys.modules["comfy.sample"], "sample_custom", fake_sample_custom),
(sys.modules["comfy.utils"], "ProgressBar", FakeProgressBar),
(sys.modules["comfy.nested_tensor"], "NestedTensor", FakeNestedTensor),
(sys.modules["comfy"], "sample", sys.modules["comfy.sample"]),
(sys.modules["comfy"], "utils", sys.modules["comfy.utils"]),
(sys.modules["comfy"], "nested_tensor", sys.modules["comfy.nested_tensor"]),
]
saved = [(mod, name, getattr(mod, name, _MISSING)) for mod, name, _ in targets]
for mod, name, value in targets:
setattr(mod, name, value)
yield
for mod, name, value in saved:
if value is _MISSING:
delattr(mod, name)
else:
setattr(mod, name, value)
_MISSING = object()
def _second_pass_run(scene_count=2, main=torch.tensor([1.0, 0.5, 0.0]),
second=torch.tensor([0.4, 0.2, 0.0])):
sample_calls.clear()
run_phase.clear()
node = FunPackLTXAVSceneChainSampler()
return node.sample(
model=FakeModel(),
vae=FakeVAE(),
positive=[scene_cond(i) for i in range(scene_count)],
negative=[(torch.zeros(1, 2, 3), {})],
sampler=object(),
sigmas=main,
seed=10,
latent_template={"samples": torch.zeros(1, 2, 5, 3, 3)},
num_frames_per_scene=5,
frame_overlap=0,
cfg=1.5,
max_scenes=8,
second_pass=True,
second_pass_sigmas=second,
)
def test_second_pass_progress_total_counts_both_passes(live_stubs):
"""A second pass samples every scene twice. If its steps aren't in the total, the bar
overflows during scene 1 and then jumps BACKWARDS at scene 2, whose offset is a
multiple of the (too small) per-scene stride."""
_second_pass_run(scene_count=2)
# 2 main steps + 2 second-pass steps, per scene, over 2 scenes.
assert [c["steps"] for c in sample_calls] == [2, 2, 2, 2]
assert FakeProgressBar.last.total == 2 * (2 + 2)
def test_progress_total_ignores_an_unusable_second_pass_schedule(live_stubs):
"""The loop skips a malformed schedule with a note, so the total must not reserve steps
for a pass that never runs — the bar would stall short of the end."""
_second_pass_run(scene_count=2, second=torch.tensor([0.4, 0.2])) # doesn't reach 0
assert [c["steps"] for c in sample_calls] == [2, 2]
assert FakeProgressBar.last.total == 2 * 2
def test_second_pass_says_which_pass_is_running(live_stubs):
"""The run report names the second pass only after the fact, which is no help while
you're waiting on one. The live label has to say it AS it happens."""
_second_pass_run(scene_count=2)
assert [c["phase"] for c in sample_calls] == [
"scene 1/2 · pass 1 of 2", "scene 1/2 · pass 2 of 2",
"scene 2/2 · pass 1 of 2", "scene 2/2 · pass 2 of 2",
]
# ...and nothing is sampling once the node returns.
assert run_phase.current()["label"] == ""
def test_phase_label_drops_the_scene_number_on_a_single_scene_run(live_stubs):
_second_pass_run(scene_count=1)
assert [c["phase"] for c in sample_calls] == ["pass 1 of 2", "pass 2 of 2"]