diff --git a/app/ui/features/sampler/hub.test.js b/app/ui/features/sampler/hub.test.js new file mode 100644 index 00000000..966333ad --- /dev/null +++ b/app/ui/features/sampler/hub.test.js @@ -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" } } }]); +}); diff --git a/app/ui/features/sampler/index.js b/app/ui/features/sampler/index.js index 78a3cbec..a981922b 100644 --- a/app/ui/features/sampler/index.js +++ b/app/ui/features/sampler/index.js @@ -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(); }; diff --git a/app/ui/features/settings/models.js b/app/ui/features/settings/models.js index 41cd68a4..f37e171e 100644 --- a/app/ui/features/settings/models.js +++ b/app/ui/features/settings/models.js @@ -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 || [])]; diff --git a/app/ui/shell/pipeline_link.js b/app/ui/shell/pipeline_link.js index 8186e78d..765971af 100644 --- a/app/ui/shell/pipeline_link.js +++ b/app/ui/shell/pipeline_link.js @@ -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); diff --git a/app/ui/shell/pipeline_state.js b/app/ui/shell/pipeline_state.js index faef8298..08cb7842 100644 --- a/app/ui/shell/pipeline_state.js +++ b/app/ui/shell/pipeline_state.js @@ -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), }; } diff --git a/app/ui/shell/tests/pipeline_link.test.js b/app/ui/shell/tests/pipeline_link.test.js index dc640d1a..3ae960cc 100644 --- a/app/ui/shell/tests/pipeline_link.test.js +++ b/app/ui/shell/tests/pipeline_link.test.js @@ -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); +}); diff --git a/app/ui/shell/tests/pipeline_state.test.js b/app/ui/shell/tests/pipeline_state.test.js index 1ead9ed1..447d968b 100644 --- a/app/ui/shell/tests/pipeline_state.test.js +++ b/app/ui/shell/tests/pipeline_state.test.js @@ -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/); +});