From b65ef1519939bce50f9581d67008b1008a7f97d8 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:14:34 +0300 Subject: [PATCH] fix: Generate refuses, saying why, until the open project's pipeline is live; Sampler shows what will really run when a save fails; takes check the right pipeline Co-Authored-By: Claude Opus 5.5 --- app/ui/boot.js | 4 ++-- app/ui/features/generate/hub.test.js | 10 ++++++++++ app/ui/features/generate/index.js | 3 ++- app/ui/features/sampler/hub.test.js | 19 +++++++++++++++++++ app/ui/features/sampler/index.js | 8 +++++++- app/ui/shell/pipeline_link.js | 3 ++- 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/app/ui/boot.js b/app/ui/boot.js index b48b8dc2..97a7c6b3 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -25,11 +25,11 @@ const on = (fn) => { heard.add(fn); return () => heard.delete(fn); }; const project = createProject({ keepConsistent: syncSeparated, onChange: () => say("change"), onOpen: () => say("open"), onError: (err) => console.warn(err), beforeSwitch: () => pipeline.settled() }); const pipeline = createPipelineState(api); const generate = createGenerate({ pipeline }); -linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "open") fn(); }), say: (text) => c.toast.warn({ text }) }); +const link = linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "open") fn(); }), say: (text) => c.toast.warn({ text }) }); const frame = build(document.getElementById("app")); const selection = createSelection({ project }); selection.on(() => say("select")); -const app = { editorSettings: [], timelineLanes: [], timelineView: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; +const app = { editorSettings: [], timelineLanes: [], timelineView: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; const settings = createSettings({ menubar: hostFor("menubar.menus") }); app.openSettings = (id) => settings.open(id); const result = await loadFeatures(paths, { app }); diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index e145bacd..20a252d1 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -83,3 +83,13 @@ test("a batch with no seed input says so and makes nothing", async () => { assert.equal(queued, 0); assert.match(document.body.textContent, /no seed input/); }); + +test("Generate refuses, saying why, while the open project's pipeline is not the live one", async () => { + let queued = 0; + const { host, app } = rig("idle", { generate: async () => { queued += 1; return true; } }); + app.pipelineOwned = () => false; + host.querySelector("button").click(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(queued, 0); + assert.match(document.body.textContent, /pipeline is not loaded yet/); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 0a26c2b9..449e5802 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -51,7 +51,6 @@ export default { // `times` > 1: the same shots again, only the seed differing (the prompt's random picks are drawn once), so the takes can be compared. async function runUnits(units, times = 1) { if (busy) { if (times > 1) tell("A run is already going: wait for it, or Stop it."); return; } - if (times > 1 && !rolesAt(app.pipeline.slots(), "generation.seed").length) return tell("This pipeline has no seed input the app controls, so the takes would all come out the same."); busy = true; stopped = false; draw(); const pid = p.project.id; let made = 0; @@ -59,6 +58,8 @@ export default { try { // A project's pipeline still going in is the one this run uses, not the last one's. if (app.pipeline.settled && !(await app.pipeline.settled())) { tell("Not started: this project's pipeline is still loading. ComfyUI is slow to answer; try again in a moment."); return; } + if (app.pipelineOwned && !app.pipelineOwned()) { tell("Not started: this project's pipeline is not loaded yet (ComfyUI did not answer). It goes in as soon as ComfyUI answers."); return; } + if (times > 1 && !rolesAt(app.pipeline.slots(), "generation.seed").length) { tell("This pipeline has no seed input the app controls, so the takes would all come out the same."); return; } const snap = times > 1 ? structuredClone(p.project) : null; // takes differ in the seed alone: later edits to the project do not reach them const drawn = new Map(); const same = times > 1 ? (body) => { const key = JSON.stringify(body); if (!drawn.has(key)) drawn.set(key, expand(body)); return drawn.get(key); } : expand; diff --git a/app/ui/features/sampler/hub.test.js b/app/ui/features/sampler/hub.test.js index 13dcd2d3..97cb8939 100644 --- a/app/ui/features/sampler/hub.test.js +++ b/app/ui/features/sampler/hub.test.js @@ -64,3 +64,22 @@ test("typing a value back to what the window opened with is saved, though the wi } assert.deepStrictEqual(saved, [20, 7]); }); + +test("a value that could not be saved is not left on screen: the window shows what will run and says why", async () => { + const live = [{ id: "sampler", node: "S", inputs: { steps: 7 }, roles: [{ at: "generation.sampling", input: "steps", label: "Steps" }] }]; + const ps = { slots: () => live, activeModules: () => [], useful: () => true, usefulness: () => () => true, currentValues: () => ({}), loading: () => false, loadError: () => null, + subscribe: () => () => {}, ensureLoaded: async () => {}, save: async () => {}, saveNotes: () => ["Could not save: down"] }; + const host = document.createElement("div"); + document.body.append(host); + hub.setup({ host, app: { pipeline: ps, api: { describeNodes: async () => ({ nodes: { S: { widgets: [{ name: "steps", type: "INT", min: 1, max: 100 }] } } }) }, on: () => () => {}, actions: null } }); + await new Promise((r) => setTimeout(r, 0)); + host.querySelector("button").click(); + await new Promise((r) => setTimeout(r, 0)); + let input = [...document.querySelectorAll("input[type=number]")].pop(); + input.value = "25"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + input = [...document.querySelectorAll("input[type=number]")].pop(); + assert.equal(input.value, "7"); + assert.match(document.body.textContent, /Could not save: down/); +}); diff --git a/app/ui/features/sampler/index.js b/app/ui/features/sampler/index.js index fa6cd79c..c62c54f9 100644 --- a/app/ui/features/sampler/index.js +++ b/app/ui/features/sampler/index.js @@ -24,7 +24,13 @@ export default { if (!w) return null; const set = async (v) => { // compared with the slot as it is NOW: the one drawn may be several saves old const now = (ps.slots() || []).find((s) => s.id === slot.id) || slot; - if (JSON.stringify((now.inputs || {})[role.input]) !== JSON.stringify(v)) await ps.save({ inputs: { [slot.id]: { [role.input]: v } } }); + if (JSON.stringify((now.inputs || {})[role.input]) === JSON.stringify(v)) return; + await ps.save({ inputs: { [slot.id]: { [role.input]: v } } }); + const after = (ps.slots() || []).find((s) => s.id === slot.id) || {}; + if (JSON.stringify((after.inputs || {})[role.input]) !== JSON.stringify(v)) { // not saved: show what will really run, and why + c.toast.warn({ text: [...(ps.saveNotes ? ps.saveNotes() : [])].find((n) => /could not save/i.test(n)) || `${role.label || role.input} was not saved.` }); + draw(); + } }; 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) }); }; diff --git a/app/ui/shell/pipeline_link.js b/app/ui/shell/pipeline_link.js index e51875f1..5ed98567 100644 --- a/app/ui/shell/pipeline_link.js +++ b/app/ui/shell/pipeline_link.js @@ -52,5 +52,6 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) { store(project.project.models || {}, slots); }); onOpen(() => { retries = 0; return adopt(); }); // each project opened gets its own retries - return { adopt }; + // Whether the live pipeline is the open project's (false while it is going in, or ComfyUI has not answered). + return { adopt, owns: () => !!project.project && owner === project.project.id }; }