feat(editor): hide settings the loaded model cannot use

Family is known at loader-save, so Engine Settings drops H3-dead knobs (and
LTX-dead h3_audio_clock) instead of explaining them per run. Values survive a
family switch; the now-redundant main-window chips are removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-22 18:56:25 +03:00
co-authored by Claude Opus 5
parent d77b3e2eed
commit 9f04f1de01
3 changed files with 125 additions and 28 deletions
+16 -8
View File
@@ -385,8 +385,12 @@
return value !== undefined ? depVal === value : !!depVal;
}
function knobVisible(k, si) {
function knobVisible(k, si, st) {
if (EASY() && RATING_GATED_KNOBS.has(k.name)) return false;
// The loaded model cannot use this one. Hide it rather than offer a control and then
// explain per run that it does nothing — what is not wired does not appear. The stored
// value survives, so switching family back brings the setting back with it.
if (st && window.PipelineCaps?.familyInertInputs(st).has(k.name)) return false;
// dependsOn/dependsValue: single condition (dependsValue absent = plain truthy
// gate, as every existing boolean dependsOn already relies on).
if (k.dependsOn && !_depSatisfied(k.dependsOn, k.dependsValue, si)) return false;
@@ -402,7 +406,7 @@
}
function renderSamplerKnob(parentGroup, st, k, si, multiScene) {
if (!knobVisible(k, si)) return;
if (!knobVisible(k, si, st)) return;
const val = si[k.name] != null ? si[k.name] : k.default;
const gs = normGuideSettings(st.project);
const cs = normContinuitySettings(st.project);
@@ -467,11 +471,15 @@
chain_experimental: ["context_windows", "context_window_length", "context_window_overlap", "context_window_schedule", "context_window_fuse", "context_window_freenoise", "context_window_retain_first", "plateau_cache", "plateau_cache_threshold", "h3_audio_clock", "segmented_detailing", "detail_targets", "detail_strength", "detail_threshold", "detail_max_area", "detail_mode", "detail_denoise", "mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"],
};
function countChainView(p, id) {
function countChainView(p, id, st) {
const si = p.sampler_inputs || {};
// A knob the model cannot use is hidden, so a badge counting it would advertise a
// change the user cannot see or undo from this pane.
const inert = st ? (window.PipelineCaps?.familyInertInputs(st) || new Set()) : new Set();
let n = 0;
(CHAIN_VIEW_KNOBS[id] || []).forEach((name) => {
if (EASY() && RATING_GATED_KNOBS.has(name)) return;
if (inert.has(name)) return;
const k = SAMPLER_KNOB_MAP[name];
if (k && si[name] != null && si[name] !== k.default) n++;
});
@@ -494,11 +502,11 @@
}
if (chainOn) {
out.push(
{ id: "chain_continuity", group: "Chain Sampler", title: "Continuity", icon: "∞", badge: countChainView(p, "chain_continuity") || null },
{ id: "chain_timing", group: "Chain Sampler", title: "Timing & Seed", icon: "⏱", badge: countChainView(p, "chain_timing") || null },
{ id: "chain_guidance", group: "Chain Sampler", title: "Guidance", icon: "◇", badge: countChainView(p, "chain_guidance") || null },
{ id: "chain_decode", group: "Chain Sampler", title: "Decode", icon: "▣", badge: countChainView(p, "chain_decode") || null },
{ id: "chain_experimental", group: "Chain Sampler", title: "Experimental", icon: "⚗", badge: countChainView(p, "chain_experimental") || null },
{ id: "chain_continuity", group: "Chain Sampler", title: "Continuity", icon: "∞", badge: countChainView(p, "chain_continuity", st) || null },
{ id: "chain_timing", group: "Chain Sampler", title: "Timing & Seed", icon: "⏱", badge: countChainView(p, "chain_timing", st) || null },
{ id: "chain_guidance", group: "Chain Sampler", title: "Guidance", icon: "◇", badge: countChainView(p, "chain_guidance", st) || null },
{ id: "chain_decode", group: "Chain Sampler", title: "Decode", icon: "▣", badge: countChainView(p, "chain_decode", st) || null },
{ id: "chain_experimental", group: "Chain Sampler", title: "Experimental", icon: "⚗", badge: countChainView(p, "chain_experimental", st) || null },
);
}
return out;
+37 -20
View File
@@ -149,29 +149,40 @@
+ "schedules. LTX puts both streams on one schedule, so there is nothing to correct.",
};
// Sub-settings of identity transfer: meaningless wherever the feature itself cannot run,
// so they travel with it rather than each needing its own entry above.
const IDENTITY_SUB_INPUTS = ["source_id", "phase_scale", "id_strength", "arcface_mode"];
// Non-boolean knobs H3 cannot use. v2a_grad_scale hooks LTXAV's video_to_audio_attn
// submodule; H3 has no separate video->audio cross-attention to scale, and the sampler
// forces it back to 1.0. Its parent feature (joyai_audio_memory) DOES work on H3, so only
// the coupling knob goes.
const H3_DEAD_VALUE_INPUTS = ["v2a_grad_scale"];
// The sampler inputs the LOADED model cannot use. Engine Settings hides these outright
// rather than offering a control and explaining once per run that it does nothing — a
// toggle that is not offered needs no explanation. The stored value is left untouched, so
// switching family back restores the user's setting instead of silently resetting it.
//
// Safe because the sampler already forces every one of these off for the family in
// question, so hiding removes a control that was inert either way — it never makes a
// working knob unreachable.
function familyInertInputs(st) {
if (!usesChainSampler(st)) return new Set();
if (!isH3(st)) return new Set(Object.keys(H3_ONLY_SAMPLER_INPUTS));
return new Set([...Object.keys(H3_DEAD_SAMPLER_INPUTS), ...IDENTITY_SUB_INPUTS,
...H3_DEAD_VALUE_INPUTS]);
}
// Returns [{short, detail}] for settings that are ON but cannot do anything on H3.
function h3InertSettings(st) {
const p = st && st.project;
if (!p || !usesChainSampler(st)) return [];
const si = p.sampler_inputs || {};
if (!isH3(st)) {
return Object.keys(H3_ONLY_SAMPLER_INPUTS)
.filter((key) => si[key])
.map((key) => ({
short: key.replace(/_/g, " ") + " needs MiniMax H3",
detail: H3_ONLY_SAMPLER_INPUTS[key]
+ " Turn it off in Settings → Engine to stop it showing here.",
}));
}
const issues = [];
Object.keys(H3_DEAD_SAMPLER_INPUTS).forEach((key) => {
if (si[key]) issues.push([key, H3_DEAD_SAMPLER_INPUTS[key]]);
});
issues.push(...h3DeadValueIssues(si));
const out = issues.map(([key, detail]) => ({
short: "H3: " + key.replace(/_enabled$/, "").replace(/_/g, " ") + " can't run",
detail: detail + " Turn it off in Settings → Engine to stop it showing here.",
}));
// Everything family-inert is now HIDDEN from Engine Settings, so a chip here would
// point at a control the user cannot find ("turn it off in Settings → Engine" names a
// row that is not rendered). What is left is the one issue hiding cannot express: a
// frame rate the model will not honour, which is a value the user must change.
const out = [];
const fps = frameRateIssue(st);
if (fps) out.push(fps);
return out;
@@ -297,7 +308,7 @@
return map[type] || type;
}
window.PipelineCaps = {
const api = {
usesFunpackStudio,
usesChainSampler,
caps,
@@ -309,6 +320,7 @@
snapFramesIfRequired,
frameRateIssue,
h3InertSettings,
familyInertInputs,
effectiveSourceType,
isChainOnlySource,
isT2V,
@@ -319,4 +331,9 @@
promptSourceIssue,
sourceLabel,
};
// Node can require this for the pure predicates (familyInertInputs, snapFramesTo, …);
// the browser keeps the window global. `window` does not exist under Node, so neither
// assignment may assume the other's environment.
if (typeof module !== "undefined" && module.exports) module.exports = api;
if (typeof window !== "undefined") window.PipelineCaps = api;
})();
@@ -0,0 +1,72 @@
// Which sampler settings the loaded model cannot use (pipeline_caps.js).
// Run: node --test movie_editor/frontend/pipeline_caps.test.js
//
// The rule these guard: a feature the model cannot run DISAPPEARS from Engine Settings
// rather than being offered and then explained away once per generation. So the predicate
// has to be exactly right in both directions — hiding a knob that works is worse than
// showing one that doesn't.
const test = require("node:test");
const assert = require("node:assert");
const PC = require("./pipeline_caps.js");
const h3 = { models: { model_family: "minimax_h3" }, project: {} };
const ltx = { models: { model_family: "ltxav" }, project: {} };
test("H3 hides the features that need LTX cross-attention", () => {
const inert = PC.familyInertInputs(h3);
assert.ok(inert.has("bounded_attention_enabled"));
assert.ok(inert.has("identity_transfer_enabled"));
});
test("H3 hides identity transfer's sub-settings with it, not just the toggle", () => {
const inert = PC.familyInertInputs(h3);
for (const k of ["source_id", "phase_scale", "id_strength", "arcface_mode"]) {
assert.ok(inert.has(k), `${k} should travel with its parent feature`);
}
});
test("H3 hides v2a_grad_scale but keeps the feature it couples to", () => {
const inert = PC.familyInertInputs(h3);
assert.ok(inert.has("v2a_grad_scale"));
// JoyAI audio memory itself works on H3 — only the LTXAV coupling knob is dead.
assert.ok(!inert.has("joyai_audio_memory"));
});
test("LTX hides the H3-only audio clock", () => {
const inert = PC.familyInertInputs(ltx);
assert.ok(inert.has("h3_audio_clock"));
});
test("neither family hides the other's live settings", () => {
const onH3 = PC.familyInertInputs(h3);
const onLtx = PC.familyInertInputs(ltx);
assert.ok(!onH3.has("h3_audio_clock"), "the audio clock is the point of H3");
assert.ok(!onLtx.has("bounded_attention_enabled"), "bounded attention is an LTX feature");
assert.ok(!onLtx.has("identity_transfer_enabled"), "Best-FaceID is an LTX feature");
});
test("nothing is hidden when the chain sampler is not in the pipeline", () => {
// Without the Chain Sampler these knobs are inert for a different reason, and the
// pipeline warning already covers it — hiding here would attribute it to the model.
const off = { models: { model_family: "minimax_h3", disable_core: true }, project: {} };
assert.strictEqual(PC.familyInertInputs(off).size, 0);
});
test("an unknown family is treated as LTX, never as 'hide everything'", () => {
const inert = PC.familyInertInputs({ models: { model_family: "wan2.2" }, project: {} });
assert.ok(!inert.has("bounded_attention_enabled"));
assert.ok(inert.has("h3_audio_clock"));
});
test("the main-window chips no longer name a control that is now hidden", () => {
// Every family-inert setting is hidden, so "turn it off in Settings → Engine" would
// point at a row that is not rendered. Only value-shaped issues survive there.
const st = {
models: { model_family: "minimax_h3" },
project: { sampler_inputs: { bounded_attention_enabled: true, identity_transfer_enabled: true } },
};
const chips = PC.h3InertSettings(st);
assert.ok(!chips.some((c) => /can't run/.test(c.short)),
"a hidden setting should not also be chipped");
});