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:
co-authored by
Claude Opus 5.5
parent
ce874b4447
commit
fd35f4582d
@@ -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" } } }]);
|
||||
});
|
||||
@@ -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(); };
|
||||
|
||||
@@ -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 || [])];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user