diff --git a/CHANGELOG.md b/CHANGELOG.md index df87d064..ba960c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +Added an **Absolute / Relative steer mode** to Refiner V2 and Studio. *Relative* (the default, unchanged behaviour) is per-prompt: it learns and applies the best conditioning for one specific prompt. *Absolute* is prompt-agnostic — it accumulates a single global "taste" prior across **every** rated generation and pulls conditioning toward it regardless of the prompt. In Absolute mode a rating means "this has (or lacks) details I like **in general**", not prompt adherence: a Perfect means "I love this, give me more of it everywhere", and a low rating means "this is missing the details I like in general". The pull layers two engines — a pooled liked/disliked direction (the learned, automated analogue of the manual Conditioning Adjust phrase shift) and the keyless value function on top — and is applied at the conditioning output (`_v2_finalize_conditioning`). *Both* keeps the per-prompt fit and layers the global prior under it; pure *Absolute* bypasses per-prompt memory entirely. The global store learns from rated runs even with no refinement key wired (it is keyless by design). The Scene Chain sampler's `embed_guidance` gains an `embed_guidance_source` (`relative` / `absolute`) so the per-step nudge can draw from the global taste direction too. Studio exposes `Steer mode` + `Absolute strength` in the refiner panel. + Ported **velocity bias + reactive rescue** to the `FunPack Distilled Flow` sampler (`sample_funpack_distilled_flow`), reusing the same capture/memory/rescue machinery as the Hybrid Euler-2S and LTXAV/RF samplers. The sampler now exposes `velocity_bias_mode`, `velocity_bias_strength`, `velocity_bias_source`, `velocity_refinement_key`, `rescue_mode`, `rescue_threshold`, and `rescue_strength`, and these are wired into Studio's **Distilled Flow** sampler config panel (with the same blank/`default` velocity-key → wired-refinement-key fallback the Hybrid panel uses). Steering is applied magnitude-preservingly and, on packed LTXAV latents, confined to the video stream (audio-safe). Note: few-step distilled schedules may only land on one or two velocity targets, so apply/rescue fire less often than on an 8-step run; they no-op cleanly when no target matches. With everything off the sampler is byte-identical to the previous deterministic ODE. ## [2.7.7] - 2026-06-03 diff --git a/conditioning.py b/conditioning.py index 66eb8d34..3ec3f842 100644 --- a/conditioning.py +++ b/conditioning.py @@ -360,6 +360,12 @@ def update_refinement_sampler_context(refinement_key, sampler_context): print(f"[FunPack] update_refinement_sampler_context: failed to save context: {e}") +# Fixed, prompt-agnostic key for the Absolute steering store. Unlike every other refinement +# key (which hashes a specific prompt), this one collects liked/disliked directions across +# ALL prompts into a single "global taste" file. Relative mode = per-prompt; Absolute = this. +FUNPACK_ABSOLUTE_KEY = "__funpack_absolute_global__" + + def refinement_state_path(refinement_key, mode, prefix="refine", extension="json"): base_dir = os.path.dirname(os.path.abspath(__file__)) refinements_dir = os.path.join(base_dir, "refinements") @@ -5950,6 +5956,16 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): "latent": ("LATENT", { "tooltip": "Optional video latent for creativity masking. Takes priority over any saved latent for this key. Connect your i2v or previous KSampler output here.", }), + "steer_mode": (["relative", "absolute", "both"], { + "default": "relative", + "label": "Steer Mode", + "tooltip": "Relative: per-prompt steering (finds the best conditioning for THIS prompt — default). Absolute: pull conditioning toward the global learned taste, regardless of prompt. Both: layer them.", + }), + "absolute_strength": ("FLOAT", { + "default": 0.6, "min": 0.0, "max": 2.0, "step": 0.05, + "label": "Absolute Strength", + "tooltip": "How hard Absolute mode pulls toward the global taste direction. 0.6 is visible but non-destructive; raise for stronger override of the prompt.", + }), }, } @@ -11371,6 +11387,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): advisor_clip=None, feedback_prompt="", prompt_repair=True, temporal_style="natural", 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, _seed=None, _seed_source="fresh seed", _scene_seeds=None, _velocity_keys=None, batch_variants=1): seed = int(_seed) if _seed is not None else random.randint(1, 0xffffffffffffffff) @@ -11490,25 +11507,17 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): # value_guidance only controls APPLICATION (ascent below) — a user who runs with it # off still builds the VF, so enabling guidance later works immediately. if has_previous_run and refinement_key and not learning_profile.get("skip_learning"): - try: - import torch as _torch - try: - from .value_function import OnlineValueFunction - except ImportError: - from value_function import OnlineValueFunction - payload = (previous_run or {}).get("conditioning") - if isinstance(payload, dict): - with _torch.inference_mode(False), _torch.enable_grad(): - cond_tensor = serializable_to_tensor(payload).clone() - reward = float(learning_profile.get("reward", 0.0)) - vf_path = refinement_state_path(refinement_key, "value_fn", prefix="refine_v2", extension="pt") - vf = OnlineValueFunction.load_or_create(vf_path, hidden_dim=cond_tensor.shape[-1]) - if vf is not None: - vf.train_on(cond_tensor, reward) - vf.save(vf_path) - print(f"[FunPackRefiner] Value function updated — {vf.n_trained} samples, buffer={len(vf.buffer_c)}") - except Exception as e: - print(f"[FunPackRefiner] Value function training failed: {e}") + n = self._v2_train_value_function( + refinement_state_path(refinement_key, "value_fn", prefix="refine_v2", extension="pt"), + (previous_run or {}).get("conditioning"), + float(learning_profile.get("reward", 0.0)), + ) + if n is not None: + print(f"[FunPackRefiner] Value function updated — {n} samples") + # Absolute store: the same rating also feeds the keyless, prompt-agnostic taste prior. + # Runs even with no refinement_key (Absolute is global), so standalone runs still build it. + if has_previous_run: + self._v2_learn_absolute(previous_run, learning_profile) if has_previous_run and not learning_profile.get("skip_learning"): self._v2_update_streaks(global_state, learning_profile, update_conditioning_strength=not prompt_only_mode) repair_feedback, repair_persistence_status = self._v2_active_repair_feedback( @@ -11771,6 +11780,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): elif prompt_only_mode: refined = cond adaptation_status = "Adaptation: Prompt only mode; conditioning vectors passed through unchanged." + elif str(steer_mode).lower() == "absolute": + # Pure Absolute: skip the per-prompt conditioning memory entirely. Steering happens + # only via the keyless global-taste pull in _v2_finalize_conditioning, so the result + # is moved toward general preferences "despite the prompt". (Both keeps per-prompt + # memory and layers the global pull on top.) + refined = cond + adaptation_status = "Adaptation: Absolute mode; per-prompt memory bypassed, global taste pull applied at output." else: _current_final = [t for t in (prompt_to_encode or "").split(",") if len(t.strip()) > 1] _prev_final = previous_run.get("final_phrase_texts", []) if isinstance(previous_run, dict) else [] @@ -12214,7 +12230,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): repair_feedback, current_family_slot, _vf_for_memory, _concept_dir, _concept_strength, _current_final) return ( - self._v2_ascend_conditioning(output_conditioning, refinement_key, apply=value_guidance), + self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength), status, training_info, loss_graph, @@ -12232,7 +12248,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): repair_feedback, current_family_slot, _vf_for_memory, _concept_dir, _concept_strength, _current_final) return ( - self._v2_ascend_conditioning(output_conditioning, refinement_key, apply=value_guidance), + self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength), status + enhancement_status, training_info, loss_graph, @@ -12379,6 +12395,177 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): print(f"[FunPackRefiner] Conditioning ascent failed: {e}") return conditioning_list + # ====================== ABSOLUTE STEERING (prompt-agnostic) ====================== + # Relative steering (everything above) keys every learned direction to one prompt's + # state file. Absolute steering accumulates the SAME pooled, channel-space directions + # into a single keyless file so they describe a global "taste" that applies to any + # prompt. It is the learned, automated analogue of FunPackConditioningAdjust: instead + # of a user typing a phrase + strength, the direction is the running average of what + # was rated liked (vs. the global source mean), applied as a broadcast shift. + + def _v2_absolute_state_path(self): + return refinement_state_path(FUNPACK_ABSOLUTE_KEY, "clip", prefix=self.V2_STATE_PREFIX) + + def _v2_absolute_vf_path(self): + return refinement_state_path(FUNPACK_ABSOLUTE_KEY, "value_fn", prefix=self.V2_STATE_PREFIX, extension="pt") + + def _v2_load_absolute_global(self): + """Load the keyless Absolute store and return (full_state, global_dict).""" + path = self._v2_absolute_state_path() + state = None + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + state = json.load(f) + except (json.JSONDecodeError, OSError, ValueError): + state = None + if not isinstance(state, dict): + state = {"version": 2, "global": {}} + global_state = state.setdefault("global", {}) + global_state.setdefault("axis_conditioning_memory", {}) + return state, global_state + + def _v2_save_absolute_global(self, state): + path = self._v2_absolute_state_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(state, f) + except OSError as e: + print(f"[FunPackRefiner] Absolute store save failed: {e}") + + def _v2_train_value_function(self, vf_path, payload, reward): + """Add one (conditioning, reward) sample to the value function at vf_path. Shared by + the per-key (relative) and keyless (absolute) reward assets. Returns n_trained or None.""" + if not isinstance(payload, dict): + return None + try: + import torch as _torch + try: + from .value_function import OnlineValueFunction + except ImportError: + from value_function import OnlineValueFunction + with _torch.inference_mode(False), _torch.enable_grad(): + cond_tensor = serializable_to_tensor(payload).clone() + vf = OnlineValueFunction.load_or_create(vf_path, hidden_dim=cond_tensor.shape[-1]) + if vf is not None: + vf.train_on(cond_tensor, float(reward)) + vf.save(vf_path) + return vf.n_trained + except Exception as e: + print(f"[FunPackRefiner] Value function training failed: {e}") + return None + + def _v2_learn_absolute(self, previous_run, learning_profile): + """Fold the just-rated generation into the prompt-agnostic Absolute store. + + Rating semantics differ from Relative here. Relative asks "does this match the + prompt?" and attributes credit per missing/satisfied axis. Absolute asks "do I like + these details IN GENERAL, regardless of the prompt?" — so we deliberately IGNORE the + prompt-specific axis attribution and steer purely on the overall reward: + * a high rating ("Perfect" = I love this and want more of this in general) pushes + the global liked direction toward this generation; + * a low rating ("missing details I like in general") pushes the disliked direction. + Pooled across every prompt, this converges on a single global-taste prior. Also trains + the keyless value function. Runs independent of refinement_key — Absolute is keyless.""" + if not isinstance(previous_run, dict) or learning_profile.get("skip_learning"): + return + payload = previous_run.get("conditioning") + if not isinstance(payload, dict): + return + reward = float(learning_profile.get("reward", 0.0)) + try: + state, global_state = self._v2_load_absolute_global() + self._v2_update_session_mean(global_state, previous_run.get("source_conditioning")) + session_mean = global_state.get("session_source_mean") + if reward >= 0.3: + # "more of this, generally" + self._v2_store_direction(global_state.setdefault("liked_dir", {}), payload, session_mean) + elif reward <= 0.0: + # "this lacks the details I like, generally" + self._v2_store_direction(global_state.setdefault("bad_dir", {}), payload, session_mean) + # the mild-positive band (0, 0.3) is too ambiguous to move either pole + global_state["total_iterations"] = int(global_state.get("total_iterations", 0)) + 1 + self._v2_save_absolute_global(state) + except Exception as e: + print(f"[FunPackRefiner] Absolute learning failed: {e}") + n = self._v2_train_value_function(self._v2_absolute_vf_path(), payload, reward) + if n is not None: + print(f"[FunPackRefiner] Absolute value function updated — {n} samples") + + def _v2_apply_absolute(self, conditioning_list, strength, value_blend=True): + """Pull every conditioning entry toward the global learned taste direction, regardless + of prompt. Two layered engines: (1) the pooled liked direction (minus a global bad-dir + repel), applied as a calibrated broadcast shift via _v2_apply_direction; (2) the keyless + value-function ascent on top for the nonlinear component. No-op until each engine has + enough data (liked/bad need direction_count>=3; VF needs MIN_SAMPLES).""" + if not conditioning_list or strength <= 0: + return conditioning_list + try: + _, global_state = self._v2_load_absolute_global() + except Exception: + return conditioning_list + liked = global_state.get("liked_dir", {}) + bad = global_state.get("bad_dir", {}) + liked_ready = int(liked.get("direction_count", 0)) >= 3 + bad_ready = int(bad.get("direction_count", 0)) >= 3 + + gvf = None + if value_blend: + try: + import torch as _torch + try: + from .value_function import OnlineValueFunction + except ImportError: + from value_function import OnlineValueFunction + vf_path = self._v2_absolute_vf_path() + if os.path.exists(vf_path): + with _torch.inference_mode(False): + candidate = OnlineValueFunction.load(vf_path) + gvf = candidate if candidate.is_ready() else None + except Exception: + gvf = None + + if not (liked_ready or bad_ready or gvf is not None): + return conditioning_list + + out = [] + applied = False + for entry in conditioning_list: + if not (isinstance(entry, (list, tuple)) and len(entry) >= 2 and isinstance(entry[0], torch.Tensor)): + out.append(entry) + continue + mixed, extra = entry[0], entry[1] + if liked_ready: + mixed = self._v2_apply_direction(mixed, liked, strength) + if bad_ready: + mixed = self._v2_apply_direction(mixed, bad, strength * 0.5, negate=True) + if gvf is not None: + try: + mixed = gvf.ascend(mixed) + except Exception: + pass + out.append((mixed, extra)) + applied = True + if applied: + print( + f"[FunPackRefiner] Absolute steer applied (strength={strength}, " + f"liked={liked_ready}, bad={bad_ready}, value_fn={'on' if gvf is not None else 'off'})" + ) + return out + + def _v2_finalize_conditioning(self, conditioning_list, refinement_key, value_guidance, + steer_mode, absolute_strength): + """Single output hook for both steering modes. Relative = per-key VF ascend (current + behaviour). Absolute = global taste pull. Both = layer them.""" + mode = str(steer_mode or "relative").lower() + out = conditioning_list + if mode in ("relative", "both"): + out = self._v2_ascend_conditioning(out, refinement_key, apply=value_guidance) + if mode in ("absolute", "both"): + out = self._v2_apply_absolute(out, float(absolute_strength)) + return out + class FunPackSaveRefinementLatent: CATEGORY = "FunPack/Refinement" @@ -13576,6 +13763,8 @@ class FunPackStudio: split_transition_placement=split_transition_placement, reference_injection=reference_injection, value_guidance=value_guidance, + steer_mode=str(rf.get("steer_mode", "relative") or "relative"), + absolute_strength=float(rf.get("absolute_strength", 0.6) or 0.6), latent=latent, seed_output_connected=seed_output_connected, _seed=seed, diff --git a/samplers.py b/samplers.py index af92d0e1..3e2057cc 100644 --- a/samplers.py +++ b/samplers.py @@ -1694,6 +1694,10 @@ class FunPackLTXAVSceneChainSampler: "default": 0.02, "min": 0.005, "max": 0.1, "step": 0.005, "tooltip": "Per-step nudge strength toward the liked conditioning direction. Keep small — the direction is applied at every step so it compounds. 0.01-0.03 is typical.", }), + "embed_guidance_source": (["relative", "absolute"], { + "default": "relative", + "tooltip": "Which learned direction embed_guidance steers toward. Relative: this prompt's liked direction (needs refinement_key_input). Absolute: the global, prompt-agnostic taste direction the Refiner accumulates across all prompts — works with no key.", + }), "transition_duration": ("INT", { "default": 16, "min": 0, "max": 128, "step": 2, "tooltip": "Extra pixel frames of fade beyond the blend zone on each side of a scene boundary. 0 = disable all transition effects.", @@ -2163,6 +2167,14 @@ class FunPackLTXAVSceneChainSampler: except Exception: return None + def _absolute_key(self): + """The keyless global-taste key for Absolute embed_guidance (mirrors the Refiner).""" + try: + from .conditioning import FUNPACK_ABSOLUTE_KEY + except ImportError: + from conditioning import FUNPACK_ABSOLUTE_KEY + return FUNPACK_ABSOLUTE_KEY + def _load_value_function(self, refinement_key): """Load the online value function if trained and ready.""" try: @@ -2314,6 +2326,7 @@ class FunPackLTXAVSceneChainSampler: carry_i2v_guides=False, mid_scene_guide=False, mid_scene_guide_strength=0.4, embed_guidance=False, embed_guidance_strength=0.02, + embed_guidance_source="relative", transition_duration=16, decode_tile_size=0, refinement_key_input="", unique_id=None, prompt=None): if not isinstance(positive, list) or not positive: @@ -2330,7 +2343,7 @@ class FunPackLTXAVSceneChainSampler: num_frames_per_scene, frame_overlap, cfg, max_scenes, use_same_seed, carry_i2v_guides, mid_scene_guide, mid_scene_guide_strength, embed_guidance, embed_guidance_strength, transition_duration, - decode_tile_size, refinement_key_input, + decode_tile_size, refinement_key_input, embed_guidance_source, ) max_scene_count = max(1, int(max_scenes)) @@ -2346,21 +2359,25 @@ class FunPackLTXAVSceneChainSampler: boundary_entries = [] cumulative_latent_frames = 0 - # Load liked direction once for embed_guidance + # Load liked direction once for embed_guidance. Source selects which learned direction: + # 'relative' = this prompt's key; 'absolute' = the global, prompt-agnostic taste store + # (keyless, so it works even without refinement_key_input). _liked_dir = None _value_fn = None - if embed_guidance and refinement_key_input: - _liked_dir = self._load_liked_direction(refinement_key_input) + _eg_source = str(embed_guidance_source or "relative").lower() + _eg_key = self._absolute_key() if _eg_source == "absolute" else refinement_key_input + if embed_guidance and _eg_key: + _liked_dir = self._load_liked_direction(_eg_key) if _liked_dir is None: - print("[FunPackSceneChain] embed_guidance: no liked direction found (need 3+ liked generations)") + print(f"[FunPackSceneChain] embed_guidance ({_eg_source}): no liked direction found (need 3+ liked generations)") else: - _value_fn = self._load_value_function(refinement_key_input) + _value_fn = self._load_value_function(_eg_key) if _value_fn: ready = _value_fn.is_ready() mode = f"value function ({_value_fn.n_trained} samples, ascent {'on' if ready else 'pending'})" else: mode = "fixed direction" - print(f"[FunPackSceneChain] embed_guidance: active via {mode}, strength={embed_guidance_strength}") + print(f"[FunPackSceneChain] embed_guidance ({_eg_source}): active via {mode}, strength={embed_guidance_strength}") first_scene_seed = self._scene_seed(scene_conditionings[0]) if first_scene_seed is None: @@ -2560,7 +2577,7 @@ class FunPackLTXAVSceneChainSampler: latent_template, num_frames_per_scene, frame_overlap, cfg, max_scenes, use_same_seed, carry_i2v_guides, mid_scene_guide, mid_scene_guide_strength, embed_guidance, embed_guidance_strength, transition_duration, - decode_tile_size, refinement_key_input): + decode_tile_size, refinement_key_input, embed_guidance_source="relative"): """Sample one chain per Studio-packed variant entry (seed + index), persisting each result (latent + preview + per-entry cond + manifest) under ComfyUI temp for rating in Studio. Reuses sample() per entry with only the seed changed, so each entry is a clean generation.""" @@ -2573,7 +2590,8 @@ class FunPackLTXAVSceneChainSampler: num_frames_per_scene, frame_overlap, cfg, max_scenes, use_same_seed=use_same_seed, carry_i2v_guides=carry_i2v_guides, mid_scene_guide=mid_scene_guide, mid_scene_guide_strength=mid_scene_guide_strength, embed_guidance=embed_guidance, - embed_guidance_strength=embed_guidance_strength, transition_duration=transition_duration, + embed_guidance_strength=embed_guidance_strength, embed_guidance_source=embed_guidance_source, + transition_duration=transition_duration, decode_tile_size=decode_tile_size, refinement_key_input="", ) stamp = _time.strftime("%Y-%m-%d_%H-%M-%S") @@ -2597,7 +2615,8 @@ class FunPackLTXAVSceneChainSampler: num_frames_per_scene, frame_overlap, cfg, max_scenes, use_same_seed=use_same_seed, carry_i2v_guides=carry_i2v_guides, mid_scene_guide=mid_scene_guide, mid_scene_guide_strength=mid_scene_guide_strength, embed_guidance=embed_guidance, - embed_guidance_strength=embed_guidance_strength, transition_duration=transition_duration, + embed_guidance_strength=embed_guidance_strength, embed_guidance_source=embed_guidance_source, + transition_duration=transition_duration, decode_tile_size=decode_tile_size, refinement_key_input=key, unique_id=None, prompt=None, ) diff --git a/web/funpack_studio.js b/web/funpack_studio.js index 89d69ef3..2636bb76 100644 --- a/web/funpack_studio.js +++ b/web/funpack_studio.js @@ -69,7 +69,7 @@ function defaultSettings() { refinement_key: "", overrides: { refinement_key: false, feedback_prompt: false, user_intent_prompt: false, negative_prompt: false }, scene_builder: { mode: "Pass-through", scene: NONE_SENTINEL, scene_name: "", aliases: "", scene_positive: "", scene_negative: "" }, - refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, prompt_repair: 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 }, + refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, prompt_repair: 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 }, advisor_llm: { enabled: false, model_path: "huihui-ai/Huihui-Qwen3-8B-abliterated-v2", dtype: "bfloat16" }, loras: [], loras_config: { mode: "ltx2", per_block: false }, @@ -1204,6 +1204,26 @@ function openPanel(node) { valueGuidanceToggle.inp.addEventListener("change", () => { settings.refiner.value_guidance = valueGuidanceToggle.inp.checked; }); body.append(el("div", "funpack-studio-hint", "The value function always trains in the background on rated generations (cheap, no extra diffusion). This toggle applies the learned reward direction to conditioning (ascent + search) once 10+ gens are rated — on by default. Turn off for strict prompt fidelity; learning still accumulates either way. The chain sampler's 'embed_guidance' uses the same trained function per-step.")); body.append(row("Value guidance", valueGuidanceToggle.wrap)); + + if (!settings.refiner.steer_mode) settings.refiner.steer_mode = "relative"; + const steerSelect = selectEl(["relative", "absolute", "both"], settings.refiner.steer_mode); + steerSelect.addEventListener("change", () => { settings.refiner.steer_mode = steerSelect.value; }); + body.append(el("div", "funpack-studio-hint", + "Relative: per-prompt steering — finds the best conditioning for THIS prompt (the default; learns from prompt-specific ratings). " + + "Absolute: pulls conditioning toward a global learned taste that applies to ANY prompt; in this mode a rating means 'this has/lacks details I like in general' (Perfect = 'I love this, give me more of it everywhere'), independent of the prompt. " + + "Both: layer the global taste prior under the per-prompt fit.")); + body.append(row("Steer mode", steerSelect)); + + if (settings.refiner.absolute_strength === undefined) settings.refiner.absolute_strength = 0.6; + const absStrengthInput = numInput(settings.refiner.absolute_strength, 0.0, 2.0, 0.05); + absStrengthInput.addEventListener("change", () => { + const v = parseFloat(absStrengthInput.value); + settings.refiner.absolute_strength = Number.isFinite(v) ? Math.max(0, Math.min(2, v)) : 0.6; + }); + body.append(el("div", "funpack-studio-hint", + "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)); + const vfActiveKey = () => settings.refinement_key || linkedRefinementKey(node); const vfExportBtn = btn("Export", "secondary"); const vfImportBtn = btn("Import", "secondary");