Files
olivv-cs-ComfyUI-FunPack/movie_editor/frontend/engine_settings.js
T
digital-garbageandClaude Fable 5 70dcc74692 sampler: block steering - rating-learned per-block residual gains (experimental)
RLHF at the model level: which REGIONS of the transformer were doing the
work when a generation came out good or bad, and steer accordingly.

1. Measure: tagged forward hooks record each block's relative video-residual
   delta per model call (near-zero cost; covered by the existing leak-strip).
2. Attribute: the end-of-run fingerprint is staged (block_snapshot.pt, same
   stage-then-pair convention as x0_snapshot) and paired with the rating
   reward on the next Refiner cycle into liked/disliked/all-runs EMA
   profiles; when output_guidance's value function is trained, an innermost
   model-function wrapper additionally credits blocks per step by
   (value delta) x (centered activity) - converges in a handful of runs.
3. Steer: vx' = vx_in + g_b*(vx_out - vx_in) on the video stream only,
   audio untouched; scores zero-mean/unit-max so emphasis is redistributed
   rather than inflated, gains hard-capped at +/-10%. Exact no-op until the
   profile has 2+ paired runs; blocks at gain 1.0 get no hook at all.

Chain Sampler toggle + strength (append-only widgets), exposed same-change
in the editor's Chain Sampler Guidance section via the sampler_inputs
bridge. New sidecars ride the defensive <key>.* sweep in
delete_refinement_key, so atomic key deletion covers them.

Tests: toy AV-block model driving the real hooks (recorder ranking, steer
video-only scaling + native path for unit gains, credit wrapper, snapshot/
profile/scores/gains roundtrip, mild-positive band, depth-mismatch reset,
leak-strip tagging). Full suite failure set identical to the pre-existing
baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 06:42:31 +03:00

659 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Engine settings modal: Studio, Chain Sampler, continuity (moved out of Project inspector).
(function () {
const { el, clear } = window.dom;
const S = window.Store;
let overlay = null;
let unsub = null;
let _editing = false;
let _onKey = null;
const FOLD_KEY = "funpack_engine_fold_";
function foldOpen(id, def) {
const v = localStorage.getItem(FOLD_KEY + id);
return v === null ? def : v === "1";
}
function setFoldOpen(id, open) { localStorage.setItem(FOLD_KEY + id, open ? "1" : "0"); }
function field(labelText, control) {
const l = el("label", "field"); l.append(el("span", null, labelText)); l.append(control); return l;
}
function collapsibleSection(parent, id, title, defaultOpen) {
const det = el("details", "insp-fold");
det.open = foldOpen(id, defaultOpen);
det.addEventListener("toggle", () => setFoldOpen(id, det.open));
det.append(el("summary", "insp-fold-sum", title));
const inner = el("div", "insp-fold-body");
det.append(inner);
parent.append(det);
return inner;
}
function engineCard(parent, id, title, defaultOpen) {
const card = el("div", "insp-engine");
const head = el("button", "insp-engine-head");
head.type = "button";
let open = foldOpen(id, defaultOpen);
const chev = el("span", "insp-engine-chev", open ? "▾" : "▸");
const ttl = el("span", "insp-engine-title", title);
const badge = el("span", "insp-engine-badge");
badge.hidden = true;
head.append(chev, ttl, badge);
const cardBody = el("div", "insp-engine-body");
cardBody.hidden = !open;
head.onclick = () => {
open = !open;
cardBody.hidden = !open;
chev.textContent = open ? "▾" : "▸";
setFoldOpen(id, open);
};
card.append(head, cardBody);
parent.append(card);
return { body: cardBody, setBadge: (n) => { badge.textContent = n > 0 ? `${n} changed` : ""; badge.hidden = n <= 0; } };
}
function slotLabelFor(st, slotId) {
if (!slotId || slotId === "funpack") return null;
const slot = (st.models?.slots || []).find((s) => s.id === slotId);
return slot ? (slot.label || slot.node_class || slot.id) : slotId;
}
function activeSceneCount(p) {
return (p.scenes || []).filter((s) => !s.excluded).length;
}
const STUDIO_REFINER_ESSENTIALS = [
{ name: "vision_conditioning", label: "Vision conditioning", default: true },
{ name: "reference_injection", label: "Reference injection", default: false },
];
const STUDIO_REFINER_ADVANCED = [
{ name: "value_guidance", label: "Value guidance", kind: "bool", default: true },
{ name: "steer_mode", label: "Steer mode", kind: "combo", choices: ["relative", "absolute", "both"], default: "relative" },
{ name: "absolute_strength", label: "Absolute strength", kind: "float", default: 0.6, min: 0, max: 1, step: 0.05,
dependsOn: "steer_mode", dependsVals: ["absolute", "both"] },
{ name: "temporal_style", label: "Temporal style", kind: "combo",
choices: ["natural", "auto", "accelerate", "decelerate", "loop", "freeze", "pulse"], default: "natural" },
{ name: "split_transition_placement", label: "Transition placement", kind: "combo",
choices: ["start", "end", "silent"], default: "start" },
];
function parseStudioSettings(p) {
const si = p.studio_inputs || {};
let cur = {};
try { cur = JSON.parse(si.studio_settings || "{}"); } catch (_) {}
const rf = (cur.refiner && typeof cur.refiner === "object") ? cur.refiner : {};
return { si, cur, rf };
}
function countStudioChanges(p) {
const { rf } = parseStudioSettings(p);
let n = 0;
[...STUDIO_REFINER_ESSENTIALS, ...STUDIO_REFINER_ADVANCED].forEach((f) => {
const cur = rf[f.name] != null ? rf[f.name] : f.default;
if (cur !== f.default) n++;
});
n += parseAdjustments(p).filter(adjIsActive).length;
return n;
}
function persistStudioRefiner(patch, now) {
const { cur } = parseStudioSettings(S.get().project);
const rf = (cur.refiner && typeof cur.refiner === "object") ? cur.refiner : {};
const next = JSON.stringify({ ...cur, refiner: { ...rf, ...patch } });
if (now) S.setStudioInputNow("studio_settings", next);
else S.setStudioInput("studio_settings", next);
}
// Conditioning adjustments — universal per-phrase steering. Stored as the FunPackStudio
// `adjustments` widget (JSON [{phrase, strength}]), a top-level studio_input the builder
// wires straight onto the node. Each phrase is CLIP-encoded and shifts conditioning toward
// (+) / away (−) from it on EVERY generation, regardless of prompt. The backend ignores
// blank/zero rows, so we persist the list as-is (no need to prune while editing).
function parseAdjustments(p) {
const si = (p && p.studio_inputs) || {};
try {
const v = JSON.parse(si.adjustments || "[]");
return Array.isArray(v) ? v.map((i) => ({ phrase: String(i.phrase || ""), strength: +i.strength || 0 })) : [];
} catch (_) { return []; }
}
function adjIsActive(i) {
return !!String(i.phrase || "").trim() && Math.abs(+i.strength || 0) > 1e-6;
}
function persistAdjustments(items, now) {
const json = JSON.stringify(items);
if (now) S.setStudioInputNow("adjustments", json);
else S.setStudioInput("adjustments", json);
}
function renderStudioAdjustments(parent) {
const sec = collapsibleSection(parent, "studio_adjust", "Conditioning adjustments", false);
sec.append(el("div", "insp-hint",
"Universal per-phrase steering: each phrase is encoded by CLIP and shifts conditioning "
+ "toward (+) or away (−) from it on every generation, regardless of the prompt. "
+ "Typical range −0.3 to +0.3."));
const items = parseAdjustments(S.get().project);
if (!items.length) sec.append(el("div", "insp-hint", "No adjustments. Add a phrase below."));
items.forEach((item, idx) => {
const row = el("div", "fields-row studio-adjust-row");
const phrase = el("input"); phrase.type = "text"; phrase.placeholder = "phrase or word";
phrase.value = item.phrase; phrase.dataset.k = "adj-phrase-" + idx; phrase.style.flex = "1";
phrase.oninput = () => { items[idx].phrase = phrase.value; persistAdjustments(items, false); };
const strength = el("input"); strength.type = "number";
strength.step = "0.05"; strength.min = "-1"; strength.max = "1";
strength.value = item.strength; strength.dataset.k = "adj-str-" + idx; strength.style.width = "72px";
strength.title = "Positive steers toward the phrase, negative away.";
strength.oninput = () => { items[idx].strength = parseFloat(strength.value || "0"); persistAdjustments(items, false); };
const del = el("button", "btn ghost tiny", "✕"); del.type = "button"; del.title = "Remove";
del.onclick = () => { items.splice(idx, 1); persistAdjustments(items, true); };
row.append(phrase, strength, del);
sec.append(row);
});
const add = el("button", "btn ghost tiny", "+ Add phrase"); add.type = "button";
add.onclick = () => {
items.push({ phrase: "", strength: 0.1 });
persistAdjustments(items, true);
setTimeout(() => {
const inputs = document.querySelectorAll('[data-k^="adj-phrase-"]');
inputs[inputs.length - 1]?.focus();
}, 0);
};
sec.append(add);
}
function renderStudioRefinerBool(parent, rf, f) {
const cur = rf[f.name] != null ? rf[f.name] : f.default;
const ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!cur; ctrl.style.width = "auto";
ctrl.dataset.k = "rf-" + f.name;
ctrl.onchange = () => persistStudioRefiner({ [f.name]: ctrl.checked }, true);
parent.append(field(f.label, ctrl));
}
function renderStudioRefinerField(parent, rf, f) {
if (f.dependsOn) {
const depVal = rf[f.dependsOn] != null ? rf[f.dependsOn] : STUDIO_REFINER_ADVANCED.find((x) => x.name === f.dependsOn)?.default;
if (!(f.dependsVals || []).includes(depVal)) return;
}
const val = rf[f.name] != null ? rf[f.name] : f.default;
let ctrl;
if (f.kind === "bool") {
ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!val; ctrl.style.width = "auto";
ctrl.dataset.k = "rf-" + f.name;
ctrl.onchange = () => persistStudioRefiner({ [f.name]: ctrl.checked }, true);
} else if (f.kind === "combo") {
ctrl = el("select"); ctrl.dataset.k = "rf-" + f.name;
(f.choices || []).forEach((c) => { const o = new Option(c, c); if (c === val) o.selected = true; ctrl.append(o); });
ctrl.onchange = () => persistStudioRefiner({ [f.name]: ctrl.value }, true);
} else {
ctrl = el("input"); ctrl.type = "number";
if (f.step != null) ctrl.step = String(f.step);
if (f.min != null) ctrl.min = String(f.min);
if (f.max != null) ctrl.max = String(f.max);
ctrl.value = val; ctrl.dataset.k = "rf-" + f.name;
ctrl.oninput = () => persistStudioRefiner({ [f.name]: parseFloat(ctrl.value || "0") }, false);
}
parent.append(field(f.label, ctrl));
}
function renderStudioCard(parent, st) {
const p = st.project;
const { cur: curSettings, rf } = parseStudioSettings(p);
const samplers = curSettings.samplers || null;
const card = engineCard(parent, "studio_card", "FunPack Studio", true);
card.setBadge(countStudioChanges(p));
// Refinement key — project-level (feeds Studio / Chain Sampler / SaveRefinementLatent).
// "default" uses the keyless store; a custom name trains/loads its own key. Shortcuts
// bound to a non-default key layer per-scene training on top of this.
const keyCtrl = el("input"); keyCtrl.type = "text"; keyCtrl.dataset.k = "refinement_key";
keyCtrl.placeholder = "default"; keyCtrl.value = p.refinement_key || "default";
keyCtrl.onchange = () => S.patchProject({ refinement_key: (keyCtrl.value || "").trim() || "default" });
card.body.append(field("Refinement key", keyCtrl));
const ess = collapsibleSection(card.body, "studio_ess", "Essentials", true);
STUDIO_REFINER_ESSENTIALS.forEach((f) => renderStudioRefinerBool(ess, rf, f));
const refSec = collapsibleSection(card.body, "studio_ref", "Refinement", false);
STUDIO_REFINER_ADVANCED.forEach((f) => renderStudioRefinerField(refSec, rf, f));
renderStudioAdjustments(card.body);
const sampSec = collapsibleSection(card.body, "studio_samp", "Sampler algorithm", false);
function persistSamplers(updatedSamplers, quiet) {
const { cur } = parseStudioSettings(S.get().project);
const next = JSON.stringify({ ...cur, samplers: updatedSamplers });
if (quiet) S.setStudioInput("studio_settings", next);
else S.setStudioInputNow("studio_settings", next);
}
try {
window.SamplerPanel.render(sampSec, samplers,
(s) => persistSamplers(s, true),
(s) => persistSamplers(s, false));
} catch (e) {
const err = el("div", "insp-hint"); err.style.color = "var(--err,#e55)";
err.textContent = "Studio sampler panel failed to render: " + e.message;
sampSec.append(err);
}
card.body.append(el("div", "insp-hint",
"Scene text and transitions come from the timeline. Advisor, LoRA, and batch training remain in the ComfyUI Studio popup on the graph."));
}
const SAMPLER_KNOBS = [
{ name: "frame_overlap", label: "Frame overlap", kind: "int", default: 16, min: 0, max: 512, step: 8 },
{ name: "transition_duration", label: "Transition duration", kind: "int", default: 16, min: 0, max: 128, step: 2 },
{ name: "use_same_seed", label: "Same seed per scene", kind: "bool", default: false },
{ name: "carry_i2v_guides", label: "Carry i2v guides", kind: "bool", default: false, lockMulti: true },
{ name: "cfg", label: "CFG", kind: "float", default: 1.0, min: 0, max: 20, step: 0.1 },
{ name: "embed_guidance", label: "Embed guidance", kind: "bool", default: false },
{ name: "embed_guidance_source", label: "Embed mode", kind: "combo", choices: ["relative", "absolute"], default: "relative", dependsOn: "embed_guidance" },
{ name: "embed_guidance_strength", label: "Embed strength", kind: "float", default: 0.02, min: 0.005, max: 0.1, step: 0.005, dependsOn: "embed_guidance" },
{ name: "score_slider", label: "Score slider", kind: "bool", default: false },
{ name: "score_slider_strength", label: "Slider strength (eta)", kind: "float", default: 1.0, min: 0, max: 3, step: 0.25, dependsOn: "score_slider" },
{ name: "output_guidance", label: "Output guidance", kind: "bool", default: false },
{ name: "output_guidance_strength", label: "Output guidance strength", kind: "float", default: 0.02, min: 0.005, max: 0.1, step: 0.005, dependsOn: "output_guidance" },
{ name: "block_steer", label: "Block steering", kind: "bool", default: false },
{ name: "block_steer_strength", label: "Block steer strength", kind: "float", default: 0.05, min: 0.01, max: 0.15, step: 0.005, dependsOn: "block_steer" },
{ name: "decode_noise_scale", label: "Decode noise scale", kind: "float", default: 0.0, min: 0, max: 1, step: 0.01 },
{ name: "decode_timestep", label: "Decode timestep", kind: "float", default: 0.05, min: 0, max: 1, step: 0.01 },
{ name: "decode_tile_size", label: "Decode tile size", kind: "int", default: 0, min: 0, max: 4096, step: 64 },
{ name: "mid_scene_guide", label: "Mid-scene guide", kind: "bool", default: false },
{ name: "mid_scene_guide_strength", label: "Guide strength", kind: "float", default: 0.25, min: 0.25, max: 0.5, step: 0.05, dependsOn: "mid_scene_guide" },
{ name: "joyai_memory", label: "JoyAI-Echo memory", kind: "bool", default: false },
{ name: "joyai_memory_size", label: "Memory size", kind: "int", default: 7, min: 1, max: 32, step: 1, dependsOn: "joyai_memory" },
{ name: "joyai_fix_frames", label: "Pinned anchors", kind: "int", default: 3, min: 0, max: 16, step: 1, dependsOn: "joyai_memory" },
{ name: "joyai_frame_select", label: "Frame select", kind: "combo", choices: ["center", "first", "random"], default: "center", dependsOn: "joyai_memory" },
{ name: "joyai_memory_strength", label: "Memory strength", kind: "float", default: 0.3, min: 0.25, max: 10.0, step: 0.05, dependsOn: "joyai_memory" },
{ name: "joyai_audio_memory", label: "Paired audio memory", kind: "bool", default: false, dependsOn: "joyai_memory" },
{ name: "v2a_grad_scale", label: "Video→audio coupling", kind: "float", default: 1.0, min: 0.0, max: 4.0, step: 0.25, dependsOn: "joyai_audio_memory" },
{ name: "alg_blur_guides", label: "Blur i2v guides and JoyAI memory", kind: "bool", default: false },
{ name: "bounded_attention_enabled", label: "Bounded attention (multi-subject)", kind: "bool", default: false },
];
const CONTINUITY_DEFAULTS = {
auto_enabled: true,
identity_pin_ref: null,
identity_pin_strength: 0.35,
prior_scene_guides: true,
prior_scene_strength: 0.35,
mid_scene_guide: true,
mid_scene_guide_strength: 0.3,
guide_decay: 0.85,
solo_scene_guides: true,
};
function normContinuitySettings(p) {
const cs = (p && p.continuity_settings) || {};
return {
auto_enabled: cs.auto_enabled !== false,
identity_pin_ref: cs.identity_pin_ref || null,
identity_pin_strength: cs.identity_pin_strength != null ? +cs.identity_pin_strength : CONTINUITY_DEFAULTS.identity_pin_strength,
prior_scene_guides: cs.prior_scene_guides !== false,
prior_scene_strength: cs.prior_scene_strength != null ? +cs.prior_scene_strength : CONTINUITY_DEFAULTS.prior_scene_strength,
mid_scene_guide: cs.mid_scene_guide !== false,
mid_scene_guide_strength: cs.mid_scene_guide_strength != null ? +cs.mid_scene_guide_strength : CONTINUITY_DEFAULTS.mid_scene_guide_strength,
guide_decay: cs.guide_decay != null ? +cs.guide_decay : CONTINUITY_DEFAULTS.guide_decay,
solo_scene_guides: cs.solo_scene_guides !== false,
};
}
function patchContinuitySettings(patch) {
const p = S.get().project;
S.patchProject({ continuity_settings: { ...(p.continuity_settings || {}), ...patch } });
}
function normGuideSettings(p) {
const gs = (p && p.guide_settings) || {};
return { stack_enabled: !!gs.stack_enabled, accumulate_prior: !!gs.accumulate_prior };
}
function patchGuideSettings(patch) {
const p = S.get().project;
S.patchProject({ guide_settings: { ...(p.guide_settings || {}), ...patch } });
}
function renderContinuitySettings(parent, st, multiScene) {
const cs = normContinuitySettings(st.project);
const gs = normGuideSettings(st.project);
const hint = el("div", "insp-hint");
hint.textContent = cs.auto_enabled
? (gs.stack_enabled
? "Auto continuity: mid-scene guide only — custom guide stack overrides auto guide lists."
: "Auto continuity builds hidden guides per run: identity pin (all modes), prior-scene guides on carry chains and solo mixed runs, mid-scene anchor on multi-scene carry. Image / empty / generated_frame solo runs use their anchor only.")
: "Auto continuity off — use manual Chain Sampler knobs and optional custom guide stack below.";
parent.append(hint);
const autoLbl = el("label", "chk");
const autoCb = el("input"); autoCb.type = "checkbox"; autoCb.checked = cs.auto_enabled;
autoCb.dataset.k = "cs-auto";
autoCb.onchange = () => patchContinuitySettings({ auto_enabled: autoCb.checked });
autoLbl.append(autoCb, el("span", null, "Auto continuity (recommended)"));
parent.append(autoLbl);
const pin = window.MediaPicker.create({
value: cs.identity_pin_ref,
mediaBin: st.mediaBin,
noneLabel: "— no identity pin —",
onChange: (v) => patchContinuitySettings({ identity_pin_ref: v }),
});
if (!cs.auto_enabled) pin.classList.add("disabled");
parent.append(field("Identity pin (all scenes)", pin));
const adv = collapsibleSection(parent, "continuity_adv", "Advanced", false);
const priorLbl = el("label", "chk");
const priorCb = el("input"); priorCb.type = "checkbox"; priorCb.checked = cs.prior_scene_guides;
priorCb.disabled = !cs.auto_enabled;
priorCb.onchange = () => patchContinuitySettings({ prior_scene_guides: priorCb.checked });
priorLbl.append(priorCb, el("span", null, "Borrow prior-scene guides"));
adv.append(priorLbl);
const soloLbl = el("label", "chk");
const soloCb = el("input"); soloCb.type = "checkbox"; soloCb.checked = cs.solo_scene_guides;
soloCb.disabled = !cs.auto_enabled;
soloCb.onchange = () => patchContinuitySettings({ solo_scene_guides: soloCb.checked });
soloLbl.title = "Mixed mode only — image/empty/generated_frame solo runs use their anchor only";
soloLbl.append(soloCb, el("span", null, "Prior guides on solo mixed runs"));
adv.append(soloLbl);
const midLbl = el("label", "chk");
const midCb = el("input"); midCb.type = "checkbox"; midCb.checked = cs.mid_scene_guide;
midCb.disabled = !cs.auto_enabled || !multiScene;
midCb.title = multiScene ? "" : "Only applies to multi-scene carry chains";
midCb.onchange = () => patchContinuitySettings({ mid_scene_guide: midCb.checked });
midLbl.append(midCb, el("span", null, "Mid-scene layout guide (carry chains)"));
adv.append(midLbl);
const num = (label, val, key, min, max, step) => {
const i = el("input"); i.type = "number"; i.min = String(min); i.max = String(max); i.step = String(step);
i.value = val; i.disabled = !cs.auto_enabled;
i.oninput = () => patchContinuitySettings({ [key]: parseFloat(i.value || "0") });
adv.append(field(label, i));
};
num("Pin strength", cs.identity_pin_strength, "identity_pin_strength", 0.25, 0.5, 0.05);
num("Prior guide strength", cs.prior_scene_strength, "prior_scene_strength", 0.25, 0.5, 0.05);
num("Mid-scene strength", cs.mid_scene_guide_strength, "mid_scene_guide_strength", 0.25, 0.5, 0.05);
num("Guide decay / scene", cs.guide_decay, "guide_decay", 0.5, 1, 0.05);
}
function renderGuideStackSettings(parent, st, multiScene) {
const gs = normGuideSettings(st.project);
const cs = normContinuitySettings(st.project);
const hint = el("div", "insp-hint");
hint.textContent = gs.stack_enabled
? "Custom guide stack — per-scene lists in the Scene inspector. Scenes without entries use the Studio default (scene 1 template · frame 0 · apply 0)."
: (cs.auto_enabled
? "Auto continuity supplies guides at generation time (see Auto continuity above)."
: "Studio default: one i2v guide from scene 1's template on multi-scene carry runs.");
parent.append(hint);
const stackLbl = el("label", "chk");
const stackCb = el("input"); stackCb.type = "checkbox"; stackCb.checked = gs.stack_enabled;
stackCb.dataset.k = "gs-stack";
stackCb.onchange = () => patchGuideSettings({ stack_enabled: stackCb.checked });
stackLbl.append(stackCb, el("span", null, "Custom guide stack"));
parent.append(stackLbl);
if (gs.stack_enabled) {
const accLbl = el("label", "chk");
const accCb = el("input"); accCb.type = "checkbox"; accCb.checked = gs.accumulate_prior;
accCb.dataset.k = "gs-accum";
accCb.onchange = () => patchGuideSettings({ accumulate_prior: accCb.checked });
accLbl.append(accCb, el("span", null, "Stack guides from all prior scenes"));
parent.append(accLbl);
parent.append(el("div", "insp-hint", "Negative frame_idx / apply_at count from the end (e.g. −1 = last frame)."));
} else if (multiScene) {
parent.append(el("div", "insp-hint", "Carry i2v guides is auto-enabled for multi-scene runs."));
}
}
const CHAIN_SECTIONS = [
{ id: "chain_auto", title: "Auto continuity", defaultOpen: true, knobs: [] },
{ id: "chain_timing", title: "Timing", defaultOpen: false, knobs: ["frame_overlap", "transition_duration", "use_same_seed"] },
{ id: "chain_cont", title: "Manual continuity", defaultOpen: false, knobs: ["carry_i2v_guides"] },
{ id: "chain_guid", title: "Guidance", defaultOpen: false, knobs: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength", "score_slider", "score_slider_strength", "output_guidance", "output_guidance_strength", "block_steer", "block_steer_strength"] },
{ id: "chain_dec", title: "Decode", defaultOpen: false, knobs: ["decode_noise_scale", "decode_timestep", "decode_tile_size"] },
{ id: "chain_exp", title: "Experimental", defaultOpen: false, knobs: ["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", "bounded_attention_enabled"] },
];
const SAMPLER_KNOB_MAP = Object.fromEntries(SAMPLER_KNOBS.map((k) => [k.name, k]));
function countChainChanges(p) {
const si = p.sampler_inputs || {};
let n = 0;
if (si.seed != null) n++;
SAMPLER_KNOBS.forEach((k) => {
if (si[k.name] != null && si[k.name] !== k.default) n++;
});
return n;
}
function renderSeedKnob(parent, si) {
const ctrl = el("input");
ctrl.type = "number";
ctrl.min = "0";
ctrl.placeholder = "Random each run";
ctrl.value = si.seed != null ? String(si.seed) : "";
ctrl.dataset.k = "si-seed";
ctrl.oninput = () => {
const raw = ctrl.value.trim();
if (raw === "") S.unsetSamplerInput("seed");
else S.setSamplerInput("seed", parseInt(raw, 10));
};
parent.append(field("Seed (optional)", ctrl));
parent.append(el("div", "insp-hint",
"Leave empty to randomize on every Generate. Set a fixed value here for reproducible runs (pair with Same seed per scene for multi-scene chains)."));
}
function knobVisible(k, si) {
if (k.dependsOn) {
const depVal = si[k.dependsOn] != null ? si[k.dependsOn] : SAMPLER_KNOB_MAP[k.dependsOn]?.default;
if (!depVal) return false;
}
return true;
}
function renderSamplerKnob(parent, st, k, si, multiScene) {
if (!knobVisible(k, si)) return;
const val = si[k.name] != null ? si[k.name] : k.default;
const gs = normGuideSettings(st.project);
const cs = normContinuitySettings(st.project);
const forced = k.lockMulti && multiScene && !gs.stack_enabled && cs.auto_enabled;
let ctrl;
if (k.kind === "bool") {
ctrl = el("input"); ctrl.type = "checkbox";
ctrl.checked = forced ? true : !!val;
ctrl.style.width = "auto";
ctrl.dataset.k = "si-" + k.name;
if (forced) {
ctrl.disabled = true;
ctrl.title = "Auto-enabled by Auto continuity for multi-scene carry runs";
} else {
ctrl.onchange = () => S.setSamplerInputNow(k.name, ctrl.checked);
}
} else if (k.kind === "combo") {
ctrl = el("select"); ctrl.dataset.k = "si-" + k.name;
(k.choices || []).forEach((c) => { const o = el("option", null, c); o.value = c; if (c === val) o.selected = true; ctrl.append(o); });
ctrl.onchange = () => S.setSamplerInputNow(k.name, ctrl.value);
} else {
ctrl = el("input"); ctrl.type = "number";
if (k.step != null) ctrl.step = String(k.step);
if (k.min != null) ctrl.min = String(k.min);
if (k.max != null) ctrl.max = String(k.max);
ctrl.value = val; ctrl.dataset.k = "si-" + k.name;
ctrl.oninput = () => {
const v = k.kind === "int" ? parseInt(ctrl.value || "0", 10) : parseFloat(ctrl.value || "0");
S.setSamplerInput(k.name, v);
};
}
parent.append(field(k.label + (forced ? " (auto)" : ""), ctrl));
}
function renderChainCard(parent, st) {
const p = st.project;
const si = p.sampler_inputs || {};
const multiScene = activeSceneCount(p) > 1;
const card = engineCard(parent, "chain_card", "Chain Sampler", true);
card.setBadge(countChainChanges(p));
CHAIN_SECTIONS.forEach((sec) => {
const inner = collapsibleSection(card.body, sec.id, sec.title, sec.defaultOpen);
sec.knobs.forEach((name) => {
const k = SAMPLER_KNOB_MAP[name];
if (k) renderSamplerKnob(inner, st, k, si, multiScene);
});
if (sec.id === "chain_auto") renderContinuitySettings(inner, st, multiScene);
if (sec.id === "chain_cont") renderGuideStackSettings(inner, st, multiScene);
if (sec.id === "chain_timing") {
renderSeedKnob(inner, si);
inner.append(el("div", "insp-hint",
"Transition duration controls in-decode boundary fades (from transition library). Post-render crossfades are per-clip in the scene inspector."));
}
});
}
function renderContent(container, st) {
if (!st.project) {
container.append(el("div", "pj-meta", "No project open."));
return;
}
const PC = window.PipelineCaps;
const studioOn = PC?.usesFunpackStudio(st);
const chainOn = PC?.usesChainSampler(st);
if (st.models?.disable_core) {
container.append(el("div", "insp-hint",
"Built-in FunPack pipeline is disabled — Studio and Chain Sampler settings are unavailable. Tune your workflow in Models."));
const foot = el("div", "insp-hint");
foot.append(el("span", null, "Workflow nodes: "));
const modelsLink = el("button", "btn ghost tiny", "Models & Pipeline…");
modelsLink.type = "button";
modelsLink.onclick = () => window.ModelsModal.open();
foot.append(modelsLink);
container.append(foot);
return;
}
if (!studioOn && !chainOn) {
container.append(el("div", "insp-hint",
"Neither FunPack Studio nor Chain Sampler is active — pick them under Pipeline nodes or use Models to wire custom nodes."));
}
if (studioOn || chainOn) {
const presetBar = el("div", "engine-preset-bar");
presetBar.append(el("span", "engine-preset-label", "Preset"));
Object.entries(S.ENGINE_PRESETS || {}).forEach(([key, p]) => {
const b = el("button", "btn ghost tiny", p.label || key);
b.onclick = () => S.applyEnginePreset(key);
presetBar.append(b);
});
container.append(presetBar);
}
const p = st.project;
const slots = (st.models?.slots || []);
const summary = el("div", "engine-modal-summary");
const parts = [];
parts.push(studioOn ? "FunPack Studio" : (slotLabelFor(st, p.conditioning_slot) || p.conditioning_slot));
parts.push(chainOn ? "Chain Sampler" : (slotLabelFor(st, p.sampler_slot) || p.sampler_slot));
summary.textContent = "Generating with " + parts.join(" + ");
container.append(summary);
const engTag = el("div", "insp-tag"); engTag.textContent = "Pipeline nodes"; container.append(engTag);
const engRow = el("div", "fields-row insp-engine-pickers");
const condSel = el("select"); condSel.dataset.k = "pj-cond";
[["funpack", "FunPack Studio"], ...slots.map((s) => [s.id, s.label || s.node_class || s.id])]
.forEach(([v, lbl]) => { const o = new Option(lbl, v); if ((p.conditioning_slot || "funpack") === v) o.selected = true; condSel.append(o); });
condSel.onchange = () => S.setConditioningSlot(condSel.value);
engRow.append(field("Conditioning", condSel));
const sampSel = el("select"); sampSel.dataset.k = "pj-samp";
[["funpack", "FunPack Chain Sampler"], ...slots.map((s) => [s.id, s.label || s.node_class || s.id])]
.forEach(([v, lbl]) => { const o = new Option(lbl, v); if ((p.sampler_slot || "funpack") === v) o.selected = true; sampSel.append(o); });
sampSel.onchange = () => S.setSamplerSlot(sampSel.value);
engRow.append(field("Sampler", sampSel));
container.append(engRow);
if (!studioOn) {
container.append(el("div", "insp-hint", "Custom conditioning node — wire and tune it in Models. FunPack Studio settings are hidden."));
} else {
renderStudioCard(container, st);
}
if (!chainOn) {
container.append(el("div", "insp-hint", "Custom sampler node — wire and tune it in Models. Chain Sampler settings are hidden."));
} else {
renderChainCard(container, st);
}
const foot = el("div", "insp-hint");
foot.append(el("span", null, "Unet / VAE / linked inputs: "));
const modelsLink = el("button", "btn ghost tiny", "Models & Pipeline…");
modelsLink.type = "button";
modelsLink.onclick = () => window.ModelsModal.open();
foot.append(modelsLink);
container.append(foot);
}
// Protect a text/number/range field being typed into from autosave rebuilds, but let
// checkboxes and selects rebuild immediately so their dependent controls (absolute
// strength, embed mode/strength, mid-scene strength, …) appear right after the toggle.
function shouldProtect(a, scope) {
if (!a || !a.dataset || !a.dataset.k || !scope || !scope.contains(a)) return false;
if (a.tagName === "TEXTAREA") return true;
if (a.tagName !== "INPUT") return false;
const t = (a.type || "text").toLowerCase();
return t !== "checkbox" && t !== "radio";
}
function render() {
if (!overlay) return;
const content = overlay.querySelector(".modal-content");
if (_editing) {
if (shouldProtect(document.activeElement, content)) return;
_editing = false;
}
const scrollTop = content.scrollTop;
clear(content);
renderContent(content, S.get());
content.scrollTop = scrollTop;
}
function close() {
if (unsub) { unsub(); unsub = null; }
if (_onKey) { document.removeEventListener("keydown", _onKey); _onKey = null; }
if (overlay) overlay.remove();
overlay = null;
_editing = false;
}
function open() {
if (!S.get().project) return;
if (overlay) { render(); return; }
overlay = el("div", "modal-overlay");
const modal = el("div", "modal modal-wide");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Engine Settings"));
const closeBtn = el("button", "btn ghost", "✕");
closeBtn.onclick = close;
const heRight = el("div", "modal-head-right"); heRight.append(closeBtn);
head.append(heRight);
modal.append(head);
const content = el("div", "modal-content engine-modal-content");
modal.append(content);
overlay.append(modal);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.append(overlay);
content.addEventListener("focusin", (e) => {
const t = e.target;
if (t && t.dataset && t.dataset.k) _editing = true;
});
content.addEventListener("focusout", (e) => {
const t = e.target;
if (!(t && t.dataset && t.dataset.k)) return;
_editing = false;
setTimeout(() => { if (!_editing) render(); }, 60);
});
_onKey = (e) => { if (e.key === "Escape") close(); };
document.addEventListener("keydown", _onKey);
unsub = S.subscribe(() => render());
render();
}
window.EngineSettingsModal = { open, close };
})();