The Chain Sampler reads one conditioning entry per SCENE (scene_conditionings = positive[:max_scenes]). When Studio stopped discarding the entries past the first of a wired CONDITIONING — the fix for a reference surviving while the prompt did not — those entries reached the sampler untagged, and the r2v reference block was counted as a scene of its own. The run sampled the shot twice, which from outside is "the model ran a second pass for no reason". Studio tags every pass-through entry as a companion; the sampler excludes companions when counting scenes and hands them to every scene instead, so they still reach the model. Both halves of the original bug stay fixed: nothing is dropped, and nothing is mistaken for a scene. Also fixes the liked-conditioning blend, which was the last payload path still doing a raw lerp: _v2_shape_compatible ignores sequence length by design, so a direction learned at 586 positions met a 494-position prompt, threw, and took the whole pull toward what the user rated well with it. Routed through _v2_payload_like like every other payload. Adds stage timers for the nine learning steps the first pass did not cover — 55% of a 26.9s run was accounted for, and the rest was unnamed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
9.2 KiB
Python
199 lines
9.2 KiB
Python
"""A wired positive CONDITIONING owns the prompt; Studio does not re-encode it.
|
|
|
|
CLIP used to win whenever both were connected, so Studio replaced the wired conditioning
|
|
with its own text-only encode. That is not an approximation of the same tensor. The node
|
|
that built the conditioning may have shown Qwen a reference image — and Qwen3-VL is causal,
|
|
so the vision block precedes the prompt and every prompt row is a hidden state that has
|
|
already read the picture. Studio cannot reproduce that: nothing in the editor pipeline wires
|
|
`h3_references` or `source_image`, so its encode never sees an image at all.
|
|
"""
|
|
import inspect
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
sys.path.insert(0, ".")
|
|
sys.modules.setdefault("folder_paths", types.SimpleNamespace(models_dir=""))
|
|
|
|
|
|
@pytest.fixture
|
|
def studio(monkeypatch):
|
|
import conditioning
|
|
node = conditioning.FunPackVideoRefinerV2.__new__(conditioning.FunPackVideoRefinerV2)
|
|
monkeypatch.setattr(node, "_v2_encode_prompt",
|
|
lambda *a, **k: (torch.ones(1, 4, 8), {}, "encoded from text"),
|
|
raising=False)
|
|
monkeypatch.setattr(node, "_v2_extract_conditioning",
|
|
lambda c: (c[0][0], dict(c[0][1])), raising=False)
|
|
monkeypatch.setattr(node, "_v2_text_tokenizer_status", lambda: "tokenizer ok",
|
|
raising=False)
|
|
return node
|
|
|
|
|
|
def _wired(seq=6, dim=8, **meta):
|
|
return [[torch.zeros(1, seq, dim), meta]]
|
|
|
|
|
|
def test_a_reference_carrying_conditioning_survives(studio):
|
|
"""The case that motivated the change: an r2v node's conditioning reaching the sampler
|
|
intact instead of being replaced by a text-only re-encode."""
|
|
wired = _wired(minimax_token_tags=torch.tensor([1, 1, 0, 0, 1, 1]))
|
|
cond, _meta, _note, owner = studio._v2_conditioning_source(object(), "a cat", wired)
|
|
assert owner == "CONDITIONING-owned"
|
|
assert torch.equal(cond, wired[0][0])
|
|
|
|
|
|
def test_a_text_only_conditioning_survives_too(studio):
|
|
"""Not gated on detecting a vision block. An i2v checkpoint pins its anchor as a keyframe
|
|
rather than tokenizing it, so its conditioning carries no vision block and a detector
|
|
would have missed it — while the node still prepared something Studio cannot rebuild."""
|
|
wired = _wired(minimax_token_tags=torch.tensor([1, 1, 1, 1, 1, 1]))
|
|
cond, _meta, _note, owner = studio._v2_conditioning_source(object(), "a cat", wired)
|
|
assert owner == "CONDITIONING-owned"
|
|
assert torch.equal(cond, wired[0][0])
|
|
|
|
|
|
def test_clip_keeps_every_other_job(studio):
|
|
"""Both stay connected: CLIP is still what encodes the negative and the references."""
|
|
_cond, _meta, note, _owner = studio._v2_conditioning_source(object(), "a cat", _wired())
|
|
assert "CLIP still encodes the negative and references" in note
|
|
|
|
|
|
def test_no_setting_selects_between_them(studio):
|
|
"""The wire IS the instruction. Wanting CLIP to own the prompt means not wiring a
|
|
conditioning, so there is nothing here to configure."""
|
|
import inspect
|
|
params = inspect.signature(studio._v2_conditioning_source).parameters
|
|
assert "clip_owns_prompt" not in params
|
|
assert "prefer_wired" not in params
|
|
|
|
|
|
def test_no_conditioning_wired_still_uses_clip(studio):
|
|
cond, _meta, _note, owner = studio._v2_conditioning_source(object(), "a cat", None)
|
|
assert owner == "CLIP-owned"
|
|
assert torch.equal(cond, torch.ones(1, 4, 8))
|
|
|
|
|
|
def test_an_invalid_wired_conditioning_falls_back_to_clip(studio, monkeypatch, capsys):
|
|
"""Falling through in silence would look like the wire did nothing."""
|
|
monkeypatch.setattr(studio, "_v2_extract_conditioning",
|
|
lambda c: ("not a tensor", {}), raising=False)
|
|
cond, _meta, _note, owner = studio._v2_conditioning_source(object(), "a cat", _wired())
|
|
assert owner == "CLIP-owned"
|
|
assert torch.equal(cond, torch.ones(1, 4, 8))
|
|
assert "invalid" in capsys.readouterr().out
|
|
|
|
|
|
# --- the scene split cannot rebuild what it did not encode -----------------
|
|
|
|
def test_the_split_is_skipped_when_the_conditioning_is_wired(monkeypatch, capsys):
|
|
"""The split re-encodes every scene from text and its entries replace the output
|
|
WHOLESALE. It re-establishes H3's visual conditioning from source_image /
|
|
h3_references — neither of which the editor pipeline ever sets — so a wired reference
|
|
conditioning was replaced by text-only encodes and the character became someone else.
|
|
|
|
With CLIP disconnected the split returns None and the wired conditioning survived, which
|
|
is exactly why disconnecting CLIP 'fixed' it.
|
|
"""
|
|
import conditioning
|
|
node = conditioning.FunPackVideoRefinerV2()
|
|
called = []
|
|
monkeypatch.setattr(node, "_v2_transition_scene_conditionings",
|
|
lambda *a, **k: called.append(a) or None, raising=False)
|
|
|
|
src = inspect.getsource(conditioning.FunPackVideoRefinerV2.refine_v2)
|
|
# The guard is on ownership, not on CLIP being absent.
|
|
assert 'split_by_transitions and conditioning_owner == "CONDITIONING-owned"' in src
|
|
assert "elif split_by_transitions:" in src
|
|
|
|
|
|
def test_the_skip_is_reported_not_silent():
|
|
"""A silently unsplit multi-scene run looks like the scene texts were ignored."""
|
|
import conditioning
|
|
src = inspect.getsource(conditioning.FunPackVideoRefinerV2.refine_v2)
|
|
assert "the scene split is SKIPPED" in src
|
|
assert "Disconnect it to let Studio split and encode per scene." in src
|
|
|
|
|
|
# --- a multi-entry wired conditioning keeps all of its entries --------------
|
|
|
|
def test_entries_past_the_first_are_not_discarded():
|
|
"""An r2v node emits the reference block and the encoded prompt as SEPARATE entries.
|
|
`_v2_extract_conditioning` reads entry 0 and Studio rebuilt a single-entry list around
|
|
it, so the character survived and the prompt did not — a tensor holding a vision block
|
|
and nothing to say. Symptom: the right face speaking invented syllables."""
|
|
import conditioning
|
|
src = inspect.getsource(conditioning.FunPackVideoRefinerV2.refine_v2)
|
|
assert 'conditioning_owner == "CONDITIONING-owned" and isinstance(positive_conditioning, list)' in src
|
|
# Kept AND tagged: the Chain Sampler counts one entry per scene, so an untagged extra
|
|
# entry becomes a second scene instead of riding with the first.
|
|
assert 'meta_copy["funpack_companion_conditioning"] = True' in src
|
|
assert "output_conditioning + companions" in src
|
|
|
|
|
|
def test_the_extra_entries_are_reported():
|
|
import conditioning
|
|
src = inspect.getsource(conditioning.FunPackVideoRefinerV2.refine_v2)
|
|
assert "pass through unchanged" in src
|
|
|
|
|
|
def test_the_tag_surplus_message_names_the_prompt_as_the_missing_half():
|
|
"""The first version of this message said the REFERENCE was missing. It is the opposite:
|
|
a leading run that is almost all image marks IS the reference, and what was trimmed is
|
|
the prompt."""
|
|
import conditioning
|
|
src = inspect.getsource(conditioning)
|
|
assert "are the encoded PROMPT" in src
|
|
assert "The prompt has been cut, not the tags." in src
|
|
|
|
|
|
# --- nothing re-encodes a conditioning it did not build --------------------
|
|
|
|
def test_a_wired_entry_is_marked_so_later_steps_can_tell(studio):
|
|
_cond, meta, _note, _owner = studio._v2_conditioning_source(object(), "a cat", _wired())
|
|
assert meta["funpack_conditioning_owner"] == "wired"
|
|
|
|
|
|
def test_bounded_attention_skips_a_wired_entry(studio, monkeypatch):
|
|
"""It replaces the tensor with a re-encode of the text — which has no reference in it,
|
|
because this node was never given one. 512 positions became 197: the whole conditioning
|
|
swapped for a text-only encode."""
|
|
import conditioning
|
|
monkeypatch.setattr(conditioning, "_log",
|
|
types.SimpleNamespace(failed=lambda *a, **k: None,
|
|
note_on_change=lambda *a, **k: None),
|
|
raising=False)
|
|
monkeypatch.setattr(studio, "_v2_bounded_attention_split_encode",
|
|
lambda *a, **k: (torch.ones(1, 197, 8), 40), raising=False)
|
|
|
|
wired = [[torch.zeros(1, 512, 8),
|
|
{"funpack_conditioning_owner": "wired", "funpack_scene_text": "One. Two."}]]
|
|
out = studio._v2_apply_bounded_attention(wired, object())
|
|
|
|
assert int(out[0][0].shape[1]) == 512
|
|
assert "funpack_bound_split_tokens" not in out[0][1]
|
|
|
|
|
|
def test_bounded_attention_still_runs_on_studios_own_encode(studio, monkeypatch):
|
|
"""Skipping it everywhere would neuter the feature for the graphs it was built for."""
|
|
monkeypatch.setattr(studio, "_v2_bounded_attention_split_encode",
|
|
lambda *a, **k: (torch.ones(1, 197, 8), 40), raising=False)
|
|
|
|
own = [[torch.zeros(1, 512, 8), {"funpack_scene_text": "One. Two."}]]
|
|
out = studio._v2_apply_bounded_attention(own, object())
|
|
|
|
assert out[0][1]["funpack_bound_split_tokens"] == 40
|
|
|
|
|
|
def test_every_finalize_stage_is_length_checked():
|
|
"""A stage that rebuilds the tensor showed up only as a tag mismatch three functions
|
|
away, which read like housekeeping."""
|
|
import conditioning
|
|
src = inspect.getsource(conditioning.FunPackVideoRefinerV2._v2_finalize_conditioning)
|
|
for name in ("pulse temporal", "rapid temporal", "auto temporal", "bounded attention",
|
|
"relative steering", "absolute steering"):
|
|
assert f'_step("{name}"' in src
|
|
assert "changed the conditioning length" in src
|