diff --git a/CHANGELOG.md b/CHANGELOG.md index 48134eec..15e62058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,17 @@ past 40vh, can be selected, and carries a copy button. The running readout is unchanged. ### Added +- **The negative prompt does something at CFG 1 (experimental).** MiniMax H3 always runs at + CFG 1.0, so the negative branch is never evaluated and the negative prompt is dead weight. + Studio ▸ "Use the negative prompt" encodes it anyway, pools it to one direction, and takes + that direction out of the positive conditioning. Default mode is `project` — it removes + only the part of each word that points at the negative and leaves the rest alone — with + `subtract` kept for comparison. The vision span of an H3 conditioning is never touched. + Off by default and unproven: expect concrete things to behave better than quality words. +- **Studio ▸ "Skip Studio's positive processing".** With both CLIP and a positive CONDITIONING + wired, CLIP has always won and the wired conditioning was ignored. This inverts that: the + wired conditioning owns the positive while CLIP keeps encoding the negative and the + references. It skips shortcuts, $variables and the scene split, which is the point. - **A different sampler for the second pass.** The Scene Chain Sampler takes an optional `second_pass_sampler`; unwired, pass 2 reuses pass 1's, which is the old behaviour. In the Editor, Second pass ▸ Own sampler reveals the full algorithm panel for pass 2 — what diff --git a/conditioning.py b/conditioning.py index 9e744710..5790f8f3 100644 --- a/conditioning.py +++ b/conditioning.py @@ -7068,7 +7068,26 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): return f"stand-in tokenizer: {source}" if source else "stand-in tokenizer loaded" def _v2_conditioning_source(self, clip, prompt_text, positive_conditioning, encode_cache=None, - reference_image=None, h3_references=None): + reference_image=None, h3_references=None, prefer_wired=False): + # `prefer_wired` inverts the usual precedence. CLIP normally wins because that is what + # a graph with both connected almost always means — but CLIP is also what encodes the + # negative, the references and Bounded Attention's spans, so "I built the positive + # myself" previously cost all of those. With this on, the wired CONDITIONING owns the + # positive and CLIP keeps every other job. + if prefer_wired and positive_conditioning is not None: + clip_note = ("positive CONDITIONING is wired and owns the prompt (Skip Studio's " + "positive processing is on)" + + ("; CLIP still encodes the negative and references" + if clip is not None else "")) + cond, meta = self._v2_extract_conditioning(positive_conditioning) + if isinstance(cond, torch.Tensor): + if not getattr(self, "_funpack_prefer_wired_noted", False): + self._funpack_prefer_wired_noted = True + print(f"[FunPackStudio] {clip_note}") + return cond, meta, clip_note, "CONDITIONING-owned" + # Falling through to CLIP silently would look like the switch did nothing. + print("[FunPackStudio] Skip Studio's positive processing is on, but the wired " + "positive CONDITIONING is invalid — falling back to the CLIP prompt path.") if clip is not None: cond, meta, encode_status = self._v2_encode_prompt( clip, prompt_text, encode_cache=encode_cache, reference_image=reference_image, @@ -11996,6 +12015,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): split_by_transitions=False, split_transition_placement="start", reference_injection=False, value_guidance=True, latent=None, seed_output_connected=False, steer_mode="relative", absolute_strength=0.6, + prefer_wired_conditioning=False, _seed=None, _seed_source="fresh seed", _scene_seeds=None, _velocity_keys=None, batch_variants=1, guess_mode=False, guess_direction="up", guess_range=1.0, guess_freeze_seed=True, movie_editor_scene_ratings=None, scene_segments=None, @@ -12454,6 +12474,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): encode_cache=encode_cache, reference_image=source_image, h3_references=_h3_references, + prefer_wired=prefer_wired_conditioning, ) fallback_graph = render_refinement_loss_graph(refinement_key, "v2", "clip", 0, 0.0, []) if not isinstance(cond, torch.Tensor): @@ -14960,6 +14981,7 @@ class FunPackStudio: user_intent_prompt=effective_intent, refinement_key_input=refinement_key_input, positive_conditioning=positive_conditioning, + prefer_wired_conditioning=bool(rf.get("prefer_wired_conditioning", False)), clip_vision_output=clip_vision_output, source_image=source_image if vision_conditioning else None, model=model, @@ -15023,6 +15045,29 @@ class FunPackStudio: model=out_model, ) + # Give the negative prompt a job at CFG 1. H3 never evaluates the negative branch, so + # the text you wrote there is otherwise dead weight; this takes its direction out of + # the positive conditioning instead. Last, so it operates on the conditioning the DiT + # will actually receive rather than on something later steering would move again. + if bool(rf.get("negative_erase", False)): + try: + try: + from . import negative_erase as _neg + except ImportError: + import negative_erase as _neg + cond, _neg_note = _neg.apply( + cond, out_negative, + float(rf.get("negative_erase_strength", 0.5) or 0.0), + mode=str(rf.get("negative_erase_mode", "project") or "project"), + renorm=bool(rf.get("negative_erase_renorm", True)), + ) + if _neg_note: + status = f"{status}\n{_neg_note}" + print(f"[FunPackStudio] {_neg_note}") + except Exception as _e: # noqa: BLE001 + status = f"{status}\nnegative_erase failed: {_e}" + print(f"[FunPackStudio] negative_erase failed ({_e}) — conditioning unchanged") + cond = _h3_reconcile_token_tags(cond, "positive") out_negative = _h3_reconcile_token_tags(out_negative, "negative") return (out_model, cond, out_negative, seed, high_sampler, high_sigmas, low_sampler, low_sigmas, loss_graph, status, training_info, encoded_prompts, video_latent) diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index 7c09369d..f666a5eb 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -101,6 +101,19 @@ { name: "split_transition_placement", label: "Transition placement", kind: "combo", choices: ["start", "end", "silent"], default: "start", hint: "Where a transition sentence lands when a prompt is split into scenes: the start of the next scene, the end of the previous one, or neither." }, + { name: "negative_erase", label: "Use the negative prompt", kind: "bool", default: false, + hint: "EXPERIMENTAL. MiniMax H3 runs at CFG 1, so it never reads the negative prompt and what you type there does nothing. This encodes it anyway and takes its direction out of the positive conditioning instead. Unproven: expect concrete things ('a hat', 'red') to work better than vague quality words." }, + { name: "negative_erase_strength", label: "Negative strength", kind: "float", default: 0.5, min: 0, max: 2, step: 0.05, + dependsOn: "negative_erase", dependsVals: [true], + hint: "1.0 removes the negative's component completely; below that is partial, above pushes past it into the opposite. Start at 0.5 — this changes the prompt the model sees, so it can lose the prompt as well as the thing you did not want." }, + { name: "negative_erase_mode", label: "Negative mode", kind: "combo", choices: ["project", "subtract"], default: "project", + dependsOn: "negative_erase", dependsVals: [true], + hint: "'project' removes only the part of each word that points at the negative and leaves the rest alone. 'subtract' moves every word by the same amount whether or not it had anything to do with it — closer to what CFG does, and blunter." }, + { name: "negative_erase_renorm", label: "Keep prompt strength", kind: "bool", default: true, + dependsOn: "negative_erase", dependsVals: [true], + hint: "Puts each word back to its original strength after the change, so the result reads as 'less of that thing' rather than 'quieter prompt'. Off is the raw result." }, + { name: "prefer_wired_conditioning", label: "Skip Studio's positive processing", kind: "bool", default: false, + hint: "When a positive CONDITIONING is wired into Studio, use it as-is instead of Studio's own prompt path — while CLIP keeps encoding the negative and the references. Normally CLIP wins and the wired conditioning is ignored. For building the positive yourself; it skips shortcuts, $variables and the scene split." }, ]; function parseStudioSettings(p) { diff --git a/negative_erase.py b/negative_erase.py new file mode 100644 index 00000000..225c5ee9 --- /dev/null +++ b/negative_erase.py @@ -0,0 +1,155 @@ +"""Use the negative prompt at CFG 1, by erasing its direction from the positive conditioning. + +MiniMax H3 runs at CFG 1.0 always, which means the negative branch is never evaluated and the +negative prompt you wrote does nothing at all. This gives it a job: encode it, pool it to one +direction, and take that direction out of the positive conditioning before the DiT sees it. + +**Projection, not subtraction.** `h - a*n` moves every token by the same fixed vector whether or +not it had anything to do with the negative concept, and its magnitude is arbitrary. `h - a*(h.n)n` +removes only the COMPONENT of each token that lies along the negative — a token orthogonal to it +is untouched, and at a=1 the result is exactly orthogonal instead of somewhere past it. Plain +subtraction is kept as a mode because it is what CFG's arithmetic looks like and the comparison is +worth having, but it is not the default. + +This is the same class of operation as embed_guidance (nudge the conditioning along a unit +direction), which is validated. What is unvalidated is whether a natural-language negative has a +clean linear presence in the encoder's hidden states. Expect concrete nouns ("a hat", "red") to +behave better than quality adjectives ("ugly", "low quality") — the latter are vague in text space +and probably vague in embedding space too. + +Pure tensor math, no ComfyUI imports, so it is testable without a model. +""" + +MODES = ("project", "subtract") + + +def _torch(): + import torch + return torch + + +def _text_positions(meta, length): + """Indices this operation may touch: text positions only when the layout says so. + + H3 packs [text | vision | audio] into one conditioning sequence and tags each position + with its modality (1 = text). Erasing a text direction out of the VISION span would + corrupt the reference image's conditioning, which is not what a negative PROMPT was asked + to do. No tags (LTX and friends) means the whole sequence is text. + """ + torch = _torch() + tags = (meta or {}).get("minimax_token_tags") + if tags is None: + return None + try: + flat = tags.reshape(-1) + if int(flat.numel()) != int(length): + return None # tags and conditioning disagree — touch nothing selectively + idx = (flat == 1).nonzero(as_tuple=False).reshape(-1) + return idx if int(idx.numel()) else None + except Exception: + return None + + +def direction(neg_tensor, neg_meta=None): + """The unit direction the negative prompt occupies, or None. + + Pooled over the negative's own text positions: the negative is a different length from the + positive, so there is no position-for-position correspondence to subtract. One direction + applied to every positive token is the honest reading of "this concept, less of it". + """ + torch = _torch() + if neg_tensor is None or not hasattr(neg_tensor, "dim"): + return None + t = neg_tensor.detach().float() + if t.dim() == 3: + t = t[0] + if t.dim() != 2 or t.shape[0] == 0: + return None + idx = _text_positions(neg_meta, t.shape[0]) + if idx is not None: + t = t.index_select(0, idx.to(t.device)) + v = t.mean(dim=0) + n = float(v.norm()) + if not n or n != n: # zero or NaN: no direction to speak of + return None + return v / n + + +def erase(pos_tensor, unit, strength, meta=None, mode="project", renorm=True): + """Take `unit` out of every text position of `pos_tensor`. Returns a new tensor. + + `renorm` puts each touched token back on its original norm afterwards, changing its + direction without changing its magnitude. The DiT is sensitive to conditioning scale, and + projection always shrinks — without this, a large strength reads partly as "quieter + prompt" rather than purely as "less of that concept". + """ + torch = _torch() + if pos_tensor is None or unit is None or not strength: + return pos_tensor + mode = mode if mode in MODES else "project" + out = pos_tensor.detach().float().clone() + squeeze = out.dim() == 3 + work = out[0] if squeeze else out + if work.dim() != 2 or work.shape[-1] != int(unit.shape[-1]): + return pos_tensor + u = unit.to(device=work.device, dtype=work.dtype) + + idx = _text_positions(meta, work.shape[0]) + rows = work if idx is None else work.index_select(0, idx.to(work.device)) + before = rows.norm(dim=-1, keepdim=True) + + if mode == "project": + rows = rows - float(strength) * (rows @ u).unsqueeze(-1) * u + else: + # Scale-free subtraction: the step is a fraction of the row's OWN magnitude, so the + # same strength means the same thing on any encoder rather than depending on how + # large this model's hidden states happen to be. + rows = rows - float(strength) * before * u + + if renorm: + after = rows.norm(dim=-1, keepdim=True).clamp_min(1e-6) + rows = rows * (before / after) + + if idx is None: + work = rows + else: + work = work.index_copy(0, idx.to(work.device), rows) + result = work.unsqueeze(0) if squeeze else work + return result.to(dtype=pos_tensor.dtype, device=pos_tensor.device) + + +def apply(positive, negative, strength, mode="project", renorm=True): + """Erase the negative's direction from every entry of a CONDITIONING list. + + Returns (conditioning, note). The note says what happened — including when nothing did, + because a negative prompt that turns out to be empty must not look like a setting that + silently failed. + """ + if not strength or not isinstance(positive, list) or not positive: + return positive, "" + if not isinstance(negative, list) or not negative: + return positive, "negative_erase: no negative conditioning to take out" + try: + neg_t = negative[0][0] + neg_m = negative[0][1] if len(negative[0]) > 1 else {} + unit = direction(neg_t, neg_m) + except Exception as exc: # noqa: BLE001 + return positive, f"negative_erase: could not read the negative conditioning ({exc})" + if unit is None: + return positive, "negative_erase: the negative conditioning has no usable direction" + + out, touched = [], 0 + for entry in positive: + try: + tensor, meta = entry[0], (entry[1] if len(entry) > 1 else {}) + new = erase(tensor, unit, strength, meta=meta, mode=mode, renorm=renorm) + if new is not tensor: + touched += 1 + out.append((new, meta) + tuple(entry[2:])) + except Exception: # noqa: BLE001 + out.append(entry) + if not touched: + return positive, "negative_erase: no conditioning entry could be modified" + return out, (f"negative_erase: {mode} {float(strength):.2f} on {touched} " + f"conditioning entr{'y' if touched == 1 else 'ies'}" + f"{'' if renorm else ', norm not restored'}") diff --git a/tests/test_negative_erase.py b/tests/test_negative_erase.py new file mode 100644 index 00000000..2735d7cb --- /dev/null +++ b/tests/test_negative_erase.py @@ -0,0 +1,157 @@ +"""Using the negative prompt at CFG 1 by erasing its direction from the positive. + +MiniMax H3 never evaluates the negative branch, so the negative prompt is dead weight. These +tests pin what the replacement actually does — and, more importantly, what it refuses to do: +touch the vision span, change a token that has nothing to do with the negative, or fail +silently when there is nothing to work with. +""" +import sys +from pathlib import Path + +import pytest +import torch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import negative_erase as ne # noqa: E402 + + +def _cond(t, meta=None): + return [(t, dict(meta or {}))] + + +# --- the direction --------------------------------------------------------------------- + + +def test_direction_is_the_pooled_unit_vector(): + t = torch.tensor([[[2.0, 0.0], [4.0, 0.0]]]) + d = ne.direction(t) + assert torch.allclose(d, torch.tensor([1.0, 0.0])) + + +def test_direction_pools_only_the_negatives_text_positions(): + """H3 packs [text | vision | audio] into one sequence. Pooling the vision span into the + 'negative prompt direction' would make an image the model was shown part of what gets + removed from the prompt.""" + t = torch.tensor([[[1.0, 0.0], [0.0, 9.0]]]) + meta = {"minimax_token_tags": torch.tensor([1, 0])} + assert torch.allclose(ne.direction(t, meta), torch.tensor([1.0, 0.0])) + + +def test_a_zero_negative_has_no_direction(): + assert ne.direction(torch.zeros(1, 3, 4)) is None + + +def test_mismatched_tags_are_ignored_rather_than_trusted(): + """Tags shorter than the conditioning mean the two disagree; selecting on them would pick + the wrong rows. Fall back to pooling everything.""" + t = torch.tensor([[[1.0, 0.0], [0.0, 1.0]]]) + d = ne.direction(t, {"minimax_token_tags": torch.tensor([1])}) + assert d is not None and float(d[1]) > 0 + + +# --- projection ------------------------------------------------------------------------ + + +def test_project_removes_the_component_and_leaves_the_rest(): + """The whole reason projection is the default: a token orthogonal to the negative is not + moved at all, while a token aligned with it loses that alignment.""" + u = torch.tensor([1.0, 0.0]) + pos = torch.tensor([[[3.0, 4.0], [0.0, 5.0]]]) + out = ne.erase(pos, u, 1.0, renorm=False) + assert float(out[0, 0, 0]) == pytest.approx(0.0) # the aligned part is gone + assert float(out[0, 0, 1]) == pytest.approx(4.0) # the rest is untouched + assert torch.allclose(out[0, 1], pos[0, 1]) # orthogonal token: unmoved + + +def test_strength_scales_the_removal(): + u = torch.tensor([1.0, 0.0]) + out = ne.erase(torch.tensor([[[2.0, 1.0]]]), u, 0.5, renorm=False) + assert float(out[0, 0, 0]) == pytest.approx(1.0) + + +def test_above_one_pushes_past_orthogonal_into_the_opposite(): + u = torch.tensor([1.0, 0.0]) + out = ne.erase(torch.tensor([[[2.0, 1.0]]]), u, 2.0, renorm=False) + assert float(out[0, 0, 0]) == pytest.approx(-2.0) + + +def test_renorm_keeps_each_tokens_strength(): + """Projection always shrinks. Without this, a big strength reads partly as a quieter + prompt rather than purely as less of the unwanted thing.""" + u = torch.tensor([1.0, 0.0]) + pos = torch.tensor([[[3.0, 4.0]]]) + out = ne.erase(pos, u, 1.0, renorm=True) + assert float(out.norm()) == pytest.approx(5.0, abs=1e-4) + assert float(out[0, 0, 0]) == pytest.approx(0.0, abs=1e-4) + + +def test_subtract_moves_every_token_by_the_same_relative_amount(): + """The blunt mode, kept for comparison: it moves a token that had nothing to do with the + negative just as much as one that did.""" + u = torch.tensor([1.0, 0.0]) + out = ne.erase(torch.tensor([[[0.0, 1.0]]]), u, 0.5, mode="subtract", renorm=False) + assert float(out[0, 0, 0]) == pytest.approx(-0.5) # an orthogonal token IS moved + + +def test_the_vision_span_of_the_positive_is_never_touched(): + """Erasing a text direction out of the reference image's conditioning is not what a + negative PROMPT was asked to do.""" + u = torch.tensor([1.0, 0.0]) + pos = torch.tensor([[[1.0, 0.0], [1.0, 0.0]]]) + meta = {"minimax_token_tags": torch.tensor([1, 0])} + out = ne.erase(pos, u, 1.0, meta=meta, renorm=False) + assert float(out[0, 0, 0]) == pytest.approx(0.0) # text position: erased + assert float(out[0, 1, 0]) == pytest.approx(1.0) # vision position: untouched + + +def test_dtype_and_device_survive(): + u = torch.tensor([1.0, 0.0]) + pos = torch.tensor([[[3.0, 4.0]]], dtype=torch.float16) + assert ne.erase(pos, u, 1.0).dtype == torch.float16 + + +# --- the list-level entry point -------------------------------------------------------- + + +def test_apply_returns_a_note_saying_what_it_did(): + pos = _cond(torch.tensor([[[3.0, 4.0]]])) + neg = _cond(torch.tensor([[[1.0, 0.0]]])) + out, note = ne.apply(pos, neg, 1.0) + assert "negative_erase" in note and "project" in note + assert float(out[0][0][0, 0, 0]) == pytest.approx(0.0, abs=1e-4) + + +def test_strength_zero_is_a_true_no_op(): + pos = _cond(torch.tensor([[[3.0, 4.0]]])) + out, note = ne.apply(pos, _cond(torch.tensor([[[1.0, 0.0]]])), 0.0) + assert out is pos and note == "" + + +def test_an_empty_negative_says_so_instead_of_going_quiet(): + """A switch that is on and doing nothing has to say which. Silence reads as 'it worked'.""" + pos = _cond(torch.tensor([[[3.0, 4.0]]])) + out, note = ne.apply(pos, [], 1.0) + assert out is pos and "no negative conditioning" in note + + +def test_a_useless_negative_says_so_too(): + pos = _cond(torch.tensor([[[3.0, 4.0]]])) + out, note = ne.apply(pos, _cond(torch.zeros(1, 2, 2)), 1.0) + assert out is pos and "no usable direction" in note + + +def test_every_scene_entry_is_treated(): + """A multi-scene prompt is a list of conditionings; erasing from only the first would + make the negative apply to scene 1 alone.""" + pos = [(torch.tensor([[[3.0, 4.0]]]), {}), (torch.tensor([[[5.0, 12.0]]]), {})] + out, note = ne.apply(pos, _cond(torch.tensor([[[1.0, 0.0]]])), 1.0) + assert "2 conditioning entries" in note + assert all(float(e[0][0, 0, 0]) == pytest.approx(0.0, abs=1e-3) for e in out) + + +def test_a_dimension_mismatch_leaves_the_conditioning_alone(): + pos = _cond(torch.tensor([[[3.0, 4.0, 5.0]]])) + out, note = ne.apply(pos, _cond(torch.tensor([[[1.0, 0.0]]])), 1.0) + assert "no conditioning entry could be modified" in note + assert out is pos diff --git a/web/funpack_studio.js b/web/funpack_studio.js index 0915e73d..6e72d519 100644 --- a/web/funpack_studio.js +++ b/web/funpack_studio.js @@ -85,7 +85,7 @@ function defaultSettings() { return { refinement_key: "", overrides: { refinement_key: false, feedback_prompt: false, user_intent_prompt: false, negative_prompt: false }, - refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural", split_by_transitions: false, split_transition_placement: "start", reference_injection: false, vision_conditioning: true, value_guidance: true, steer_mode: "relative", absolute_strength: 0.6 }, + refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural", split_by_transitions: false, split_transition_placement: "start", reference_injection: false, vision_conditioning: true, value_guidance: true, steer_mode: "relative", absolute_strength: 0.6, negative_erase: false, negative_erase_strength: 0.5, negative_erase_mode: "project", negative_erase_renorm: true, prefer_wired_conditioning: false }, advisor_llm: { enabled: false, model_path: "huihui-ai/Huihui-Qwen3-8B-abliterated-v2", dtype: "bfloat16" }, loras: [], loras_config: { mode: "ltx2", per_block: false }, @@ -1097,6 +1097,53 @@ function openPanel(node) { "How hard Absolute/Both pull toward the global taste direction. 0.6 is visible but non-destructive; raise to override the prompt more strongly.")); body.append(row("Absolute strength", absStrengthInput)); + // Using the negative prompt at CFG 1. H3 never evaluates the negative branch, so the text + // is otherwise dead; this takes its direction out of the positive conditioning instead. + const negOn = el("input"); negOn.type = "checkbox"; + negOn.checked = !!settings.refiner.negative_erase; + negOn.addEventListener("change", () => { + settings.refiner.negative_erase = negOn.checked; renderRefiner(); + }); + body.append(row("Use the negative prompt", negOn)); + body.append(el("div", "funpack-studio-hint", + "EXPERIMENTAL. At CFG 1 the negative prompt is never read and does nothing. This encodes " + + "it and removes its direction from the positive conditioning instead. Unproven — " + + "concrete things ('a hat', 'red') should behave better than vague quality words.")); + if (settings.refiner.negative_erase) { + const negStr = numInput(settings.refiner.negative_erase_strength ?? 0.5, 0, 2, 0.05); + negStr.addEventListener("change", () => { + const v = parseFloat(negStr.value); + settings.refiner.negative_erase_strength = Number.isFinite(v) ? Math.max(0, Math.min(2, v)) : 0.5; + }); + body.append(row("Negative strength", negStr)); + body.append(el("div", "funpack-studio-hint", + "1.0 removes the negative's component completely; below is partial, above pushes into " + + "the opposite. Start at 0.5 — this changes the prompt the model sees, so it can lose " + + "the prompt as well as the thing you did not want.")); + const negMode = selectEl(["project", "subtract"], settings.refiner.negative_erase_mode || "project"); + negMode.addEventListener("change", () => { settings.refiner.negative_erase_mode = negMode.value; }); + body.append(row("Negative mode", negMode)); + body.append(el("div", "funpack-studio-hint", + "'project' removes only the part of each word that points at the negative. 'subtract' " + + "moves every word by the same amount either way — closer to CFG, and blunter.")); + const negRe = el("input"); negRe.type = "checkbox"; + negRe.checked = settings.refiner.negative_erase_renorm !== false; + negRe.addEventListener("change", () => { settings.refiner.negative_erase_renorm = negRe.checked; }); + body.append(row("Keep prompt strength", negRe)); + } + + // Who owns the positive when both CLIP and a CONDITIONING are wired. + const preferWired = el("input"); preferWired.type = "checkbox"; + preferWired.checked = !!settings.refiner.prefer_wired_conditioning; + preferWired.addEventListener("change", () => { + settings.refiner.prefer_wired_conditioning = preferWired.checked; + }); + body.append(row("Skip Studio's positive processing", preferWired)); + body.append(el("div", "funpack-studio-hint", + "Normally CLIP wins and a wired positive CONDITIONING is ignored. With this on the wired " + + "one owns the positive and CLIP keeps encoding the negative and the references. It " + + "skips shortcuts, $variables and the scene split — for building the positive yourself.")); + const vfActiveKey = () => settings.refinement_key || linkedRefinementKey(node); const vfExportBtn = btn("Export", "secondary"); const vfImportBtn = btn("Import", "secondary");