fix: project switches mid-load no longer cross-write pipelines; unrecognised model files say why nothing hides; Sampler window has steps/sampler/scheduler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-05 05:32:37 +03:00
co-authored by Claude Opus 5.5
parent ce874b4447
commit fd35f4582d
7 changed files with 125 additions and 19 deletions
+32
View File
@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let hub;
test.before(async () => { setupDom(); hub = (await import("./index.js")).default; });
test.after(() => teardownDom());
test("the Sampler window shows the pipeline's steps and sampler, and a change saves into that slot", async () => {
const saved = [];
const slot = { id: "sampler", node: "S", inputs: { steps: 20, sampler_name: "euler", seed: 0 },
roles: [{ at: "generation.sampling", input: "steps", label: "Steps" }, { at: "generation.sampling", input: "sampler_name", label: "Sampler" }, { at: "generation.seed", input: "seed" }] };
const ps = { slots: () => [slot], activeModules: () => [], useful: () => true, currentValues: () => ({}), loading: () => false, loadError: () => null,
subscribe: () => () => {}, ensureLoaded: async () => {}, save: async (b) => { saved.push(b); } };
const api = { describeNodes: async () => ({ nodes: { S: { widgets: [{ name: "steps", type: "INT", min: 1, max: 100 }, { name: "sampler_name", type: "COMBO", choices: ["euler", "dpmpp_2m"] }, { name: "seed", type: "INT" }] } } }) };
const host = document.createElement("div");
document.body.append(host);
hub.setup({ host, app: { pipeline: ps, api, on: () => () => {}, actions: null } });
await new Promise((r) => setTimeout(r, 0));
const button = host.querySelector("button");
assert.equal(button.hidden, false, "shown with no sampling module, because the pipeline marks sampling inputs");
button.click();
await new Promise((r) => setTimeout(r, 0));
const text = document.body.textContent;
assert.match(text, /Steps/);
assert.match(text, /Sampler/);
const select = [...document.querySelectorAll("select")].find((s) => [...s.options].some((o) => o.value === "dpmpp_2m"));
select.value = "dpmpp_2m";
select.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 0));
assert.deepStrictEqual(saved, [{ inputs: { sampler: { sampler_name: "dpmpp_2m" } } }]);
});
+23 -9
View File
@@ -1,8 +1,10 @@
// "⏱ Sampler": steps, scheduler and sampler one click away, in a thin window over the timeline. Same settings as Engine ▸ Sampling,
// same values tree, so the two cannot disagree. Absent when no installed module has a sampling setting.
// "⏱ Sampler": the pipeline's steps, sampler and scheduler (the inputs its preset marks as sampling) and Engine ▸ Sampling's
// settings, one click away in a thin window over the timeline. Same pipeline and values tree as those pages, so they cannot
// disagree. Absent when the pipeline marks no sampling input and no module has a sampling setting.
import { offer } from "../../shell/actions.js";
import { composer as c } from "../../composer/composer.js";
import { settingRow, whenSatisfied } from "../settings/field.js";
import { widgetControl } from "../settings/models.js";
export default {
id: "sampler",
@@ -10,9 +12,20 @@ export default {
needs: ["pipeline"],
setup({ host, app }) {
const ps = app.pipeline;
let pop = null;
const sampling = () => ps.activeModules().filter((m) => (m.category || "") === "sampling" && Object.keys(m.settings || {}).length);
const button = c.button.sm({ label: "⏱ Sampler", tone: "neutral", title: "Steps, scheduler and sampler: one click, no modal.", onClick: () => (pop ? pop.close() : open()) });
let pop = null, specs = {};
const sampling = () => ps.activeModules().filter((m) => (m.category || "") === "sampling" && Object.keys(m.settings || {}).length && ps.useful(m));
const marked = () => (ps.slots() || []).flatMap((slot) => (slot.roles || []).filter((r) => r.at === "generation.sampling" && r.input && !Array.isArray((slot.inputs || {})[r.input])).map((role) => ({ slot, role })));
const describe = async () => {
const missing = [...new Set(marked().map(({ slot }) => slot.node))].filter((n) => !(n in specs));
if (missing.length) try { Object.assign(specs, (await app.api.describeNodes(missing)).nodes || {}); } catch { /* the rows just stay out until the next change */ }
};
const inputRow = ({ slot, role }) => {
const w = ((specs[slot.node] || {}).widgets || []).find((x) => x.name === role.input);
if (!w) return null;
const set = async (v) => { if (JSON.stringify((slot.inputs || {})[role.input]) !== JSON.stringify(v)) await ps.save({ inputs: { [slot.id]: { [role.input]: v } } }); };
return c.settingsRow.default({ label: role.label || w.name, hint: w.tooltip || "", control: widgetControl({ ...w, name: role.label || w.name }, (slot.inputs || {})[role.input] ?? w.default, set) });
};
const button = c.button.sm({ label: "⏱ Sampler", tone: "neutral", title: "Steps, sampler, scheduler and sampling settings: one click, no modal.", onClick: () => (pop ? pop.close() : open()) });
button.node.hidden = true;
host.append(button.node);
@@ -22,18 +35,19 @@ export default {
if (ps.loadError()) return body.set([c.banner.warn({ text: `Could not load: ${ps.loadError()}` })]);
const mods = sampling(), values = ps.currentValues();
const change = async (id, name, value) => { await ps.setModuleValue(id, name, value); draw(); };
body.set(mods.flatMap((m) => [mods.length > 1 ? c.label.section({ text: m.title || m.id }) : null,
...Object.entries(m.settings).filter(([, spec]) => whenSatisfied(values, m.id, spec.when)).map(([name, spec]) => settingRow(m.id, name, spec, values, change))]).filter(Boolean));
body.set([...marked().map(inputRow), ...mods.flatMap((m) => [mods.length > 1 ? c.label.section({ text: m.title || m.id }) : null,
...Object.entries(m.settings).filter(([, spec]) => whenSatisfied(values, m.id, spec.when)).map(([name, spec]) => settingRow(m.id, name, spec, values, change))])].filter(Boolean));
};
const typing = () => body.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
const off = ps.subscribe(() => { if (pop && !typing()) draw(); });
const show = () => { button.node.hidden = !sampling().length; if (button.node.hidden && pop) pop.close(); };
const off = ps.subscribe(() => { if (pop && !typing()) describe().then(draw); });
const show = () => { button.node.hidden = !sampling().length && !marked().length; if (button.node.hidden && pop) pop.close(); };
ps.ensureLoaded().then(show);
const offShow = ps.subscribe(show);
function open() {
draw();
pop = c.popover.anchored({ anchor: button, body, side: "top", align: "end", onClose: () => { pop = null; } });
describe().then(() => { if (pop) draw(); });
}
const offered = offer(app, { id: "sampler", label: "Sampler", icon: "⏱", run: () => { if (!button.node.hidden) (pop ? pop.close() : open()); } });
return () => { off(); offShow(); offered(); if (pop) pop.close(); button.node.remove(); };
+1 -1
View File
@@ -7,7 +7,7 @@ import { labelOf, shownValues, sourcesFor } from "./wiring.js";
const SEP = "\0", NEW_GROUP = "\0new";
const fed = (v) => Array.isArray(v);
const widgetControl = (w, current, set) => {
export const widgetControl = (w, current, set) => {
const label = w.name;
if (w.type === "COMBO") {
const choices = [...(w.choices || [])];
+14 -8
View File
@@ -1,16 +1,21 @@
// The pipeline travels with the project: opening a project puts ITS pipeline over the session's, and
// every landed pipeline edit becomes the project's copy (project.models). One writer -- the pipeline state.
export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
let adopted = true; // false while the open project's pipeline is not the live one: its copy is not rewritten
let retries = 0;
// The id of the project the live pipeline belongs to, or null while one is being put in (or could not be): only
// it is written. A project opened while an open or a save is still in flight must never receive the other's pipeline.
// An id, not the object: Undo puts a copy of the same project in place.
let owner = null;
let retries = 0, opens = 0;
// Only a real change is written: opening a project must not rewrite its file.
const store = (base, slots) => {
const store = (base, slots, opening) => {
const next = { ...base, slots: JSON.parse(JSON.stringify(slots || [])), removed: pipeline.removedIds(), unwired: pipeline.unwiredMap(), whole: pipeline.whole() };
if (!("whole" in base) && !next.whole) delete next.whole; // an older file reads as not whole anyway: no rewrite just to say so
if (opening && !("whole" in base)) delete next.whole; // an older file is not rewritten just to record the guess; an edit records it
if (JSON.stringify(next) !== JSON.stringify(project.project.models || {})) project.setField("models", next, { quiet: true });
};
async function adopt() {
owner = null;
const mine = ++opens, target = project.project && project.project.id;
const saved = project.project && project.project.models || {};
const own = (saved.slots || []).length > 0 || (saved.removed || []).length > 0;
let ok = false, timer;
@@ -18,11 +23,12 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
// Bounded: a hung request must not hold the project behind it.
ok = await Promise.race([pipeline.adopt(saved.slots || [], saved.removed, saved.unwired, saved.whole, project.fresh), new Promise((r) => { timer = setTimeout(() => r(false), 20000); })]);
} catch { ok = false; } finally { clearTimeout(timer); }
adopted = Boolean(ok && pipeline.slots());
if (adopted) {
if (mine !== opens) return; // opened again meanwhile (another project, or this one): the latest open handles it
if (ok && pipeline.slots()) {
owner = target;
retries = 0;
// An old project with nothing saved runs the default and stays as it is on disk; a new one keeps what it took.
if (own || project.fresh) store(saved, pipeline.slots());
if (own || project.fresh) store(saved, pipeline.slots(), true);
} else if (retries++ < 3) {
setTimeout(adopt, 5000);
} else {
@@ -31,7 +37,7 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
}
pipeline.subscribe((slots) => {
if (!project.project || !adopted) return;
if (!project.project || project.project.id !== owner) return;
store(project.project.models || {}, slots);
});
onOpen(adopt);
+4 -1
View File
@@ -26,11 +26,14 @@ export function createPipelineState(API) {
// Which traits the pipeline's chosen model has; null when that is not knowable (no file, or no model
// module recognised it, or the one that did names no traits): null means "do not filter", while a list,
// even an empty one, hides every module needing a trait it lacks. undefined: the probe failed, try again.
let modelNote = null; // why nothing is hidden for the chosen file, when no model module recognised it
async function probeModelTraits(pipelineSlots) {
const filename = currentModelFile(pipelineSlots);
modelNote = null;
if (!filename) return null;
try {
const data = await API.probeFamily(filename);
if (!data.detected && data.reason) modelNote = `${data.reason}, so every module is offered, including ones this model cannot use.`;
return data.detected && Array.isArray(data.traits) ? data.traits : null;
} catch (_) {
return undefined;
@@ -568,6 +571,6 @@ export function createPipelineState(API) {
loading: () => loading,
loadError: () => loadError,
saving: () => saving,
saveNotes: () => saveNotes,
saveNotes: () => (modelNote ? [...saveNotes, modelNote] : saveNotes),
};
}
+44
View File
@@ -38,3 +38,47 @@ test("an older file with no whole flag is not rewritten just to add one", async
await r.open();
assert.equal(r.writes.length, 0);
});
test("an older file guessed whole is not rewritten to record the guess", async () => {
const writes = [];
let opened;
const project = { project: { models: { slots, removed: [], unwired: {} } }, setField: (k, v) => { writes.push(v); } };
const pipeline = { adopt: async () => true, slots: () => slots, removedIds: () => [], unwiredMap: () => ({}), whole: () => true, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
assert.equal(writes.length, 0);
});
test("switching project while the first is still loading never writes into either the wrong pipeline", async () => {
const writes = [];
let opened, heard, release;
const A = { id: "a", models: { slots: [{ ...slots[0], inputs: { ckpt_name: "A" } }], removed: [], unwired: {}, whole: false } };
const B = { id: "b", models: { slots: [{ ...slots[0], inputs: { ckpt_name: "B" } }], removed: [], unwired: {}, whole: false } };
let live = A.models.slots;
const project = { project: A, setField: (k, v) => { writes.push([project.project.id, v.slots[0].inputs.ckpt_name]); project.project[k] = v; } };
const pipeline = {
adopt: async (saved) => { if (saved[0].inputs.ckpt_name === "A") await new Promise((r) => { release = r; }); live = saved; return true; },
slots: () => live, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: (fn) => { heard = fn; },
};
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
const first = opened();
heard(live); // a pipeline change while A is still loading
project.project = B;
await opened();
release();
await first; // A's late finish must not touch B
heard([{ ...slots[0], inputs: { ckpt_name: "B2" } }]);
assert.deepStrictEqual(writes, [["b", "B2"]]);
});
test("an Undo copy of the same project keeps receiving edits", async () => {
const writes = [];
let opened, heard;
const project = { project: { id: "a", models: { slots, removed: [], unwired: {}, whole: false } }, setField: (k, v) => { writes.push(v); project.project[k] = v; } };
const pipeline = { adopt: async () => true, slots: () => slots, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: (fn) => { heard = fn; } };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
project.project = JSON.parse(JSON.stringify(project.project));
heard([{ ...slots[0], inputs: { ckpt_name: "b" } }]);
assert.equal(writes.length, 1);
});
@@ -349,3 +349,10 @@ test("a file picked in a save is announced after its modules are known", async (
await PS.save({ inputs: { model: { ckpt_name: "picked" } } });
assert.deepStrictEqual(seen, ["new"]);
});
test("a model file no module recognises says so, and why nothing is hidden", async () => {
const PS = withCheckpoint(load([]));
lastApi.probeFamily = async () => ({ detected: false, reason: "a.ckpt: only .safetensors can be inspected without loading it" });
await PS.ensureLoaded();
assert.match(PS.saveNotes().join(" "), /only \.safetensors.*every module is offered/);
});