fix: the H3 v2a hint only fires when JoyAI audio memory is on; guard the inputs too
Two things, both from the same debugging session. v2a_grad_scale is JoyAI's coupling knob and is never installed without joyai_audio_memory (see the call site), so a left-over value with the feature off was being reported as an H3 limitation — blaming the model for a knob that would be equally inert on LTX. Both the sampler's pre-run notice and the Editor's inert-setting hint now gate on joyai_audio_memory. The NaN guard only saw the sampler's OUTPUT, which cannot tell "the model computed garbage" from "the model was handed garbage and propagated it" — and those have disjoint suspect lists. _sample_chunk now checks the incoming latent and the schedule first, and says which it was. The schedule check is there because it is hand-typed and our solvers divide by sigma (er = sigma_next / sigma): an interior zero or a repeated value is an instant Inf that poisons every value downstream. A trailing zero stays legal — it is only ever a step's target, never a divisor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9ea0be0713
commit
f40ae828a5
@@ -106,7 +106,10 @@
|
||||
// Same idea for non-boolean knobs whose neutral value means "off".
|
||||
function h3DeadValueIssues(si) {
|
||||
const out = [];
|
||||
if (Math.abs(Number(si.v2a_grad_scale ?? 1) - 1) > 1e-6) {
|
||||
// Only when joyai_audio_memory is on: v2a_grad_scale is that feature's coupling knob and
|
||||
// does nothing without it on ANY model, so flagging a left-over value as an H3 limitation
|
||||
// blames the wrong thing — it would be equally inert on LTX.
|
||||
if (si.joyai_audio_memory && Math.abs(Number(si.v2a_grad_scale ?? 1) - 1) > 1e-6) {
|
||||
out.push(["v2a_grad_scale",
|
||||
"v2a_grad_scale scales LTXAV's video→audio cross-attention module; H3 has no separate "
|
||||
+ "video→audio attention, only joint rows."]);
|
||||
|
||||
+75
-18
@@ -3037,6 +3037,71 @@ class FunPackLTXAVSceneChainSampler:
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
def _nonfinite_report(self, samples):
|
||||
"""['video (12/1000 values, dtype=...)'] for each stream carrying NaN/Inf. Empty when
|
||||
clean, and empty when the latent is not readable — the check must never be the thing
|
||||
that kills a render."""
|
||||
try:
|
||||
tensors = self._latent_tensors({"samples": samples})
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
names = ("video", "audio")
|
||||
bad = []
|
||||
for i, t in enumerate(tensors):
|
||||
if not isinstance(t, torch.Tensor):
|
||||
continue
|
||||
finite = torch.isfinite(t)
|
||||
if bool(finite.all()):
|
||||
continue
|
||||
n_bad = int((~finite).sum().item())
|
||||
label = names[i] if i < len(names) else f"stream {i}"
|
||||
bad.append(f"{label} ({n_bad}/{t.numel()} values, dtype={t.dtype})")
|
||||
return bad
|
||||
|
||||
def _assert_finite_inputs(self, samples, sigmas):
|
||||
"""Check what the chunk is ABOUT TO be sampled from, so a corrupt run says whether the
|
||||
damage arrived or was produced here.
|
||||
|
||||
Without this the output check alone cannot tell "the model computed garbage" from "the
|
||||
model was handed garbage and faithfully propagated it" — and those have disjoint
|
||||
suspect lists. The schedule is checked too because it is hand-typed: our own solvers
|
||||
divide by sigma (er = sigma_next / sigma), so an INTERIOR zero or a repeat is an
|
||||
instant Inf that then poisons every value in the tensor. A trailing zero is fine — it
|
||||
is only ever the target of the last step, never a divisor.
|
||||
"""
|
||||
where = self._current_phase_label() or "this chunk"
|
||||
bad = self._nonfinite_report(samples)
|
||||
if bad:
|
||||
raise RuntimeError(
|
||||
f"[FunPackSceneChain] {where}: the latent handed to the sampler is ALREADY "
|
||||
f"non-finite before any sampling — {'; '.join(bad)}. The model has not run "
|
||||
f"yet, so this is not the checkpoint, the LoRA or the sampler. Look at what "
|
||||
f"produced this latent: the empty-latent node, the i2v anchor encode, or (on "
|
||||
f"a later scene) the previous scene's output."
|
||||
)
|
||||
if not isinstance(sigmas, torch.Tensor) or sigmas.numel() < 2:
|
||||
return
|
||||
s = sigmas.detach().float().reshape(-1).cpu()
|
||||
if not bool(torch.isfinite(s).all()):
|
||||
raise RuntimeError(
|
||||
f"[FunPackSceneChain] {where}: the sigma schedule contains NaN/Inf "
|
||||
f"({[float(v) for v in s]}). Fix the schedule — nothing downstream can."
|
||||
)
|
||||
interior = s[:-1]
|
||||
if bool((interior <= 0).any()):
|
||||
raise RuntimeError(
|
||||
f"[FunPackSceneChain] {where}: the sigma schedule has a zero or negative "
|
||||
f"value before its last entry ({[float(v) for v in s]}). Every value except "
|
||||
f"the final one is divided by, so this produces Inf on that step and NaN "
|
||||
f"everywhere after it. Only the LAST sigma may be 0."
|
||||
)
|
||||
if bool((s[1:] >= s[:-1]).any()):
|
||||
raise RuntimeError(
|
||||
f"[FunPackSceneChain] {where}: the sigma schedule is not strictly "
|
||||
f"descending ({[float(v) for v in s]}). A repeated value makes a zero-length "
|
||||
f"step the solvers divide by; an ascending one integrates backwards."
|
||||
)
|
||||
|
||||
def _assert_finite_sample(self, sampled):
|
||||
"""Stop the run the moment a chunk comes back with NaN/Inf in it.
|
||||
|
||||
@@ -3049,28 +3114,14 @@ class FunPackLTXAVSceneChainSampler:
|
||||
A non-finite latent is unrecoverable, so this raises rather than warns. One
|
||||
isfinite() per chunk against a multi-second sample is not a cost worth weighing.
|
||||
"""
|
||||
try:
|
||||
tensors = self._latent_tensors({"samples": sampled})
|
||||
except Exception: # noqa: BLE001 — never fail the run over the check itself
|
||||
return
|
||||
names = ("video", "audio")
|
||||
bad = []
|
||||
for i, t in enumerate(tensors):
|
||||
if not isinstance(t, torch.Tensor):
|
||||
continue
|
||||
finite = torch.isfinite(t)
|
||||
if bool(finite.all()):
|
||||
continue
|
||||
n_bad = int((~finite).sum().item())
|
||||
label = names[i] if i < len(names) else f"stream {i}"
|
||||
bad.append(f"{label} ({n_bad}/{t.numel()} values, dtype={t.dtype})")
|
||||
bad = self._nonfinite_report(sampled)
|
||||
if not bad:
|
||||
return
|
||||
where = self._current_phase_label() or "this chunk"
|
||||
raise RuntimeError(
|
||||
f"[FunPackSceneChain] {where}: the sampler returned a non-finite latent — "
|
||||
f"{'; '.join(bad)}. Sampling itself ran to completion, so this is corrupt "
|
||||
f"MATH, not a wiring or memory problem. Usual causes, in the order worth "
|
||||
f"{'; '.join(bad)}. Its inputs were checked and were clean, so this was produced "
|
||||
f"HERE, by the model or the sampler math. Usual causes, in the order worth "
|
||||
f"testing: a LoRA that does not match this checkpoint (ComfyUI logs the keys "
|
||||
f"it could not apply at load — check the log), a base checkpoint whose layout "
|
||||
f"the loader mis-inferred (third-party repacks of quantised weights are the "
|
||||
@@ -3089,6 +3140,7 @@ class FunPackLTXAVSceneChainSampler:
|
||||
raise ValueError("sigmas input must be a SIGMAS tensor.")
|
||||
latent = self._clone_latent(latent)
|
||||
samples = latent["samples"]
|
||||
self._assert_finite_inputs(samples, sigmas)
|
||||
noise = comfy.sample.prepare_noise(samples, int(seed))
|
||||
|
||||
def _progress_cb(step, _denoised, _x, _total_steps):
|
||||
@@ -5488,7 +5540,12 @@ class FunPackLTXAVSceneChainSampler:
|
||||
"against LTX's 4096-wide cross-attention context, and the overlap "
|
||||
"tokens need LTX's patchifier; H3's native ref2va reference blocks "
|
||||
"are the equivalent and are not wired yet)")
|
||||
if v2a_grad_scale is not None and abs(float(v2a_grad_scale) - 1.0) > 1e-6:
|
||||
# Gated on joyai_audio_memory, not on the value alone: v2a_grad_scale is that
|
||||
# feature's coupling knob and is never installed without it (see the call site),
|
||||
# so reporting a left-over value as an H3 limitation blames the model for a knob
|
||||
# that would be equally inert on LTX.
|
||||
if (joyai_audio_memory and v2a_grad_scale is not None
|
||||
and abs(float(v2a_grad_scale) - 1.0) > 1e-6):
|
||||
_dead.append("v2a_grad_scale (hooks LTXAV's video_to_audio_attn submodule; H3 "
|
||||
"has no separate video->audio cross-attention to scale)")
|
||||
if segmented_detailing:
|
||||
|
||||
@@ -60,3 +60,58 @@ def test_the_check_never_fails_the_run_on_its_own(node):
|
||||
"""A latent shape the helper cannot read is not a reason to kill a good render."""
|
||||
node._assert_finite_sample("not a tensor")
|
||||
node._assert_finite_sample(None)
|
||||
|
||||
|
||||
# ── inputs ────────────────────────────────────────────────────────────────────
|
||||
# The output check alone cannot tell "the model computed garbage" from "the model was handed
|
||||
# garbage and propagated it faithfully", and those have disjoint suspect lists.
|
||||
|
||||
def _sigmas(*vals):
|
||||
return torch.tensor(list(vals), dtype=torch.float32)
|
||||
|
||||
|
||||
def test_clean_inputs_pass(node):
|
||||
node._assert_finite_inputs(torch.zeros(1, 4, 2, 8, 8), _sigmas(1.0, 0.5, 0.0))
|
||||
|
||||
|
||||
def test_a_latent_that_arrives_nan_blames_its_producer_not_the_model(node):
|
||||
x = torch.zeros(1, 4, 2, 8, 8)
|
||||
x[0, 0, 0, 0, 0] = float("nan")
|
||||
with pytest.raises(RuntimeError) as e:
|
||||
node._assert_finite_inputs(x, _sigmas(1.0, 0.0))
|
||||
msg = str(e.value)
|
||||
assert "ALREADY" in msg
|
||||
assert "has not run yet" in msg
|
||||
# It names the model-side suspects only to RULE THEM OUT, and points at the producer.
|
||||
assert "not the checkpoint, the LoRA or the sampler" in msg
|
||||
assert "empty-latent node" in msg
|
||||
|
||||
|
||||
def test_nan_in_the_schedule_is_named_as_the_schedule(node):
|
||||
with pytest.raises(RuntimeError) as e:
|
||||
node._assert_finite_inputs(torch.zeros(1, 4), _sigmas(1.0, float("nan"), 0.0))
|
||||
assert "sigma schedule contains NaN" in str(e.value)
|
||||
|
||||
|
||||
def test_an_interior_zero_sigma_is_caught_before_it_divides(node):
|
||||
"""Our solvers divide by sigma, so a 0 anywhere but the end is an instant Inf."""
|
||||
with pytest.raises(RuntimeError) as e:
|
||||
node._assert_finite_inputs(torch.zeros(1, 4), _sigmas(1.0, 0.0, 0.5, 0.0))
|
||||
assert "before its last entry" in str(e.value)
|
||||
|
||||
|
||||
def test_a_trailing_zero_is_fine(node):
|
||||
"""The last sigma is only ever a target, never a divisor — the normal schedule shape."""
|
||||
node._assert_finite_inputs(torch.zeros(1, 4), _sigmas(1.0, 0.6, 0.3, 0.0))
|
||||
|
||||
|
||||
def test_a_repeated_sigma_is_caught(node):
|
||||
with pytest.raises(RuntimeError) as e:
|
||||
node._assert_finite_inputs(torch.zeros(1, 4), _sigmas(1.0, 0.5, 0.5, 0.0))
|
||||
assert "strictly descending" in str(e.value)
|
||||
|
||||
|
||||
def test_an_ascending_schedule_is_caught(node):
|
||||
with pytest.raises(RuntimeError) as e:
|
||||
node._assert_finite_inputs(torch.zeros(1, 4), _sigmas(0.3, 0.9, 0.0))
|
||||
assert "strictly descending" in str(e.value)
|
||||
|
||||
Reference in New Issue
Block a user