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:
co-authored by
Claude Opus 5
parent
39533dfde4
commit
7681c599a0
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user