diff --git a/app/ui/boot.js b/app/ui/boot.js index 7473e334..9d781cb1 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -29,7 +29,7 @@ const link = linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if 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, pipelineOwned: link.owns, pipelineWhy: link.why, 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: [], beforeRun: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, pipelineWhy: link.why, 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/composer/chat.js b/app/ui/features/composer/chat.js index 1a87dfe3..0e287b91 100644 --- a/app/ui/features/composer/chat.js +++ b/app/ui/features/composer/chat.js @@ -26,10 +26,13 @@ export function chat(app, own) { const first = (r) => Object.values(r.rewrites || {})[0]; function draw(force) { if (!alive) return; - const list = rounds(), ok = last && last.ok, next = JSON.stringify([p.project && p.project.id, list, last && last.after]); + const here = ((app.pipeline && app.pipeline.slots()) || []).some((s) => s.node === NODE); + const list = rounds(), ok = last && last.ok, next = JSON.stringify([p.project && p.project.id, list, last && last.after, here]); if (!force && (next === key || (page.node.contains(document.activeElement) && document.activeElement.tagName === "TEXTAREA"))) return; // not under the caret key = next; if (!p.project) return page.set([c.hint.default({ text: "Open a project first." })]); + // Comments would reach nothing: said here, the way Enhance says it (they stay, for a pipeline that has one). + if (!here) return page.set([c.emptyState.default({ icon: "✎", title: "No prompt enhancer here", hint: "Chat comments go to the enhancer. Pick a preset that has one, or add the “FunPack Enhance Prompt” node in Settings ▸ Models & Pipeline." })]); const bubbles = [...list.flatMap((r) => [first(r) ? c.text.sm({ text: `Model: ${first(r)}` }) : null, c.text.sm({ text: `You: ${r.comment}` })]), ...(ok ? [c.hint.default({ text: `Last rewrite of: ${String(last.before || "").slice(0, 80)}` }), c.text.sm({ text: `Model: ${last.after}` })] : []), ...(!list.length && !ok ? [c.hint.default({ text: "Generate once with the enhancer on, then comment here." })] : [])].filter(Boolean); @@ -49,6 +52,7 @@ export function chat(app, own) { } const look = () => app.api.enhancerRuns().then((r) => { last = (r.runs || []).slice(-1)[0] || null; draw(); }).catch(() => {}); own(app.on((what) => { if (what === "open" || app.lastRun.n !== runs) { runs = app.lastRun.n; look(); } else draw(); })); + if (app.pipeline && app.pipeline.subscribe) own(app.pipeline.subscribe(() => draw())); // a pipeline that gains or loses the enhancer draw(true); look(); return page; diff --git a/app/ui/features/composer/enhance.test.js b/app/ui/features/composer/enhance.test.js index 808b16aa..a9f66f27 100644 --- a/app/ui/features/composer/enhance.test.js +++ b/app/ui/features/composer/enhance.test.js @@ -17,3 +17,16 @@ test("the Enhance tab follows the open project's pipeline: no enhancer there, no heard(live); assert.match(page.node.textContent, /No prompt enhancer here/); }); + +test("Chat on a pipeline with no enhancer says so instead of taking comments that would reach nothing", async () => { + const { chat } = await import("./chat.js"); + let slots = [{ id: "s", node: "Sampler" }], heard; + const app = { project: { project: { id: "P", scenes: [] }, pref: (_k, d) => d, setPref: () => {} }, selection: {}, lastRun: { n: 0 }, + api: { enhancerRuns: async () => ({ runs: [] }) }, on: () => () => {}, pipeline: { slots: () => slots, subscribe: (fn) => { heard = fn; return () => {}; } } }; + const page = chat(app, () => {}); + assert.match(page.node.textContent, /No prompt enhancer here/); + assert.equal(page.node.querySelector("textarea"), null); + slots = [{ id: "e", node: "FunPackEnhancePrompt" }]; + heard(slots, "edit"); + assert.ok(page.node.querySelector("textarea")); +}); diff --git a/app/ui/features/composer/index.js b/app/ui/features/composer/index.js index a3c3f2ee..9653db30 100644 --- a/app/ui/features/composer/index.js +++ b/app/ui/features/composer/index.js @@ -12,10 +12,11 @@ const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: " // The box follows the scenes; typing in it rewrites them (after a short pause, or when focus leaves). const story = (app, own) => { const p = app.project; - let marker = "qcut", words = ["qcut"], timer = 0, asked = 0; + let marker = "qcut", words = ["qcut"], timer = 0, asked = 0, inFlight = Promise.resolve(); const box = c.textarea.md({ label: "Story", rows: 14, value: p.project ? joinStory(p.project, marker) : "", onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => { timer = 0; apply(v); }, 700); }, onCommit: (v) => { clearTimeout(timer); timer = 0; apply(v); } }); const area = box.node, warn = c.hint.default({ text: "" }); - async function apply(text) { + function apply(text) { return (inFlight = split(text)); } + async function split(text) { if (!p.project) return; const mine = ++asked, pid = p.project.id; try { @@ -32,6 +33,10 @@ const story = (app, own) => { if (area.value !== next) box.setValue(next); } own(app.on(sync)); + // Generate runs what the box shows: text still waiting for its pause, or still being split, lands first. + const flush = async () => { if (timer) { clearTimeout(timer); timer = 0; apply(area.value); } await inFlight; }; + app.beforeRun.push(flush); + own(() => { flush().finally(() => app.beforeRun.splice(app.beforeRun.indexOf(flush) >>> 0, 1)); }); // closed mid-edit: still waited for sync(); return c.region.stack({ gap: "sm", children: [ templatesBar(app, own), diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index 0558888d..45609f8a 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -94,15 +94,13 @@ test("Generate refuses, saying why, while the open project's pipeline is not the assert.match(document.body.textContent, /Not started: this project.s pipeline is not loaded yet/); }); -test("a run uses the pipeline as it was at the click, whatever is opened or edited while it is prepared", async () => { +test("a run uses the pipeline as it was at the click, whatever is edited while its shots are made", async () => { const sent = []; - const { host, app } = rig("idle", { generate: async (a) => { sent.push(a.slots); return true; } }); - const at = [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: { steps: 20 } }]; - app.pipeline.slots = () => at; - host.querySelector("button").click(); - app.pipeline.slots = () => [{ id: "s", roles: [], inputs: { steps: 99 } }]; // another project's pipeline goes in - await new Promise((r) => setTimeout(r, 30)); - assert.ok(sent.length >= 1); + const { app, doc } = rig("idle", { generate: async (a) => { sent.push(a.slots); app.pipeline.slots = () => [{ id: "s", roles: [], inputs: { steps: 99 } }]; return true; } }); + doc.scenes.push({ id: "c", text: "y" }); + app.pipeline.slots = () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: { steps: 20 } }]; + await app.runner.units(["a", "c"]); + assert.equal(sent.length, 2); assert.ok(sent.every((s) => s[0].inputs.steps === 20)); }); @@ -134,3 +132,12 @@ test("Generate this scene clicked while a run is going says so, and queues nothi assert.match(document.body.textContent.replace(before, ""), /already going/); finish("cancelled"); }); + +test("an edit still on its way when Generate is clicked (the Story box splitting) lands before the run reads the project", async () => { + const seen = []; + const { app, doc } = rig("idle", { generate: async () => true }); + app.beforeRun = [async () => { await new Promise((r) => setTimeout(r, 10)); doc.scenes[0].text = "typed last"; }]; + app.inputHooks = [({ scene }) => { seen.push(scene.text); return {}; }]; + await app.runner.units(["a"]); + assert.deepEqual(seen, ["typed last"]); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 4b4be238..f677c72b 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -56,6 +56,7 @@ export default { let made = 0; said = false; try { + await Promise.all((app.beforeRun || []).map((f) => f().catch(() => {}))); // an edit still on its way (the Story box) lands first // 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: ${(app.pipelineWhy && app.pipelineWhy()) || "this project's pipeline is not loaded yet."}`); return; } @@ -85,7 +86,8 @@ export default { said = false; if (!(await g.generate({ sceneId: root.id, projectId: pid, inputs, slots: frozen }))) { done.cancel(); - if (!said) tell((g.run.state.error && g.run.state.error.message) || "Could not queue the run. Is ComfyUI running, and is a run already going?"); + const n = snap.scenes.findIndex((s) => s.id === root.id) + 1; // which clip: in a run of several, the toast alone does not say + if (!said) tell(`Scene ${n} was not started: ${(g.run.state.error && g.run.state.error.message) || "could not queue the run. Is ComfyUI running, and is a run already going?"}`); break; } if (stopped) g.cancel(); // Stop landed while this one was being queued diff --git a/app/ui/features/settings/models.js b/app/ui/features/settings/models.js index 5cb734dc..06cb119e 100644 --- a/app/ui/features/settings/models.js +++ b/app/ui/features/settings/models.js @@ -131,10 +131,13 @@ export const models = (app) => function mount() { if (first && first.type === "COMBO" && !(slot.inputs || {})[first.name]) return `${first.name} not set`; return shown.length ? shown.join(" · ") : `${(spec.widgets || []).length} setting${(spec.widgets || []).length === 1 ? "" : "s"}`; }; + // A server message about this slot: by its id, or refused by the name the person sees. The dot and the banner both use it. + const mine = (t, id) => t.startsWith(`${id}:`) || t.startsWith(`${id}.`) + || ((slotsNow().find((s) => s.id === id) || {}).roles || []).some((r) => r.label && t.startsWith(`${r.label} is `)); const nodeCard = (slot) => { const node = document.createElement("button"); node.type = "button"; node.className = "fp-node-card cx-focusable"; - const needs = specs[slot.node] === null || ps.incomplete().some((t) => t.startsWith(`${slot.id}:`)); + const needs = specs[slot.node] === null || ps.incomplete().some((t) => mine(t, slot.id)); const head = Object.assign(document.createElement("span"), { className: "fp-node-title", textContent: label(slot) }); const dot = Object.assign(document.createElement("span"), { className: `fp-node-state${needs ? " fp-needs" : ""}`, title: needs ? "Needs attention" : "Ready" }); const sub = Object.assign(document.createElement("span"), { className: "fp-node-sub", textContent: summary(slot) }); @@ -153,8 +156,6 @@ export const models = (app) => function mount() { function status(only) { const all = ps.incomplete(), refused = ps.refused(), notes = ps.saveNotes(); - const mine = (t, id) => t.startsWith(`${id}:`) || t.startsWith(`${id}.`) - || ((slotsNow().find((s) => s.id === id) || {}).roles || []).some((r) => r.label && t.startsWith(`${r.label} is `)); // refused by the name the person sees const incomplete = only ? all.filter((t) => mine(t, only)) : all; return [refused.length ? c.banner.warn({ text: `Could not save: ${refused.join(" ")}` }) : null, notes.length ? c.banner.info({ text: notes.join(" ") }) : null, incomplete.length ? c.banner.warn({ text: only ? incomplete.join(" ") : `Not ready to generate yet: ${incomplete.length} node${incomplete.length === 1 ? "" : "s"} still need something (the orange dots). Open one to see what.` }) : ps.queueable() && !only ? c.hint.default({ text: "Every slot is filled — this pipeline is ready to generate." }) : null]; diff --git a/app/ui/features/settings/models.test.js b/app/ui/features/settings/models.test.js index 796e09e0..e827419e 100644 --- a/app/ui/features/settings/models.test.js +++ b/app/ui/features/settings/models.test.js @@ -21,3 +21,16 @@ test("Revert goes back to the pipeline of the project now open, not the one open await new Promise((r) => setTimeout(r, 0)); assert.equal(restored, "B"); }); + +test("a node the server says needs something shows the orange dot, whichever way the message names it", async () => { + const slots = [{ id: "model", node: "L", inputs: { ckpt_name: "" } }, { id: "s", node: "S", inputs: {}, roles: [{ at: "project.video", input: "amount", label: "Length" }] }]; + const ps = { slots: () => slots, snapshot: () => ({ slots }), ensureLoaded: async () => {}, subscribe: () => () => {}, restore: async () => ({ refused: [] }), + loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => ["model.ckpt_name is '', which is not one of: a", "Length is 5000, above the largest 4096 it takes"], + refused: () => [], queueable: () => false, removedIds: () => [], offered: () => [] }; + const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes: { L: { widgets: [] }, S: { widgets: [] } } }) } })(); + document.body.append(page.node); + await new Promise((r) => setTimeout(r, 0)); + const dots = [...page.node.querySelectorAll(".fp-node-state")]; + assert.equal(dots.length, 2); + assert.ok(dots.every((d) => d.classList.contains("fp-needs"))); +}); diff --git a/app/ui/shell/pipeline_state.js b/app/ui/shell/pipeline_state.js index 66d48df3..553b74ed 100644 --- a/app/ui/shell/pipeline_state.js +++ b/app/ui/shell/pipeline_state.js @@ -646,21 +646,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) { return (adoptedOk = false); } - // Every slot's own values as they are NOW, as per-run overrides: a Generate is several runs, and the - // settings are global to it -- an edit made while it runs reaches the next Generate, never half of - // this one. Inputs another node feeds (arrays) are left to that node. - function frozenInputs() { - const out = {}; - (slots || []).forEach((s) => { - const own = {}; - Object.entries(s.inputs || {}).forEach(([k, v]) => { if (v !== undefined && !Array.isArray(v)) own[k] = JSON.parse(JSON.stringify(v)); }); - out[s.id] = own; - }); - return out; - } - return { - frozenInputs, ensureLoaded, save, edit, restore, snapshot, setGroup, adopt, settled, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue, modulesById: () => modulesById, setEditGuard: (fn) => { editGuard = fn; }, adoptedOk: () => adoptedOk, unreachable: () => slots === null || unreachableLast, refusal: () => refusalLast, activeModules, useful, usefulness, isOff, setOff, allOff, setAllOff, refreshControl, control: () => controlState, diff --git a/app/ui/shell/tests/pipeline_state.test.js b/app/ui/shell/tests/pipeline_state.test.js index c6cd2b23..d779e6dc 100644 --- a/app/ui/shell/tests/pipeline_state.test.js +++ b/app/ui/shell/tests/pipeline_state.test.js @@ -283,19 +283,6 @@ test("a project opened after another does not inherit the first one's pending sw assert.strictEqual(PS.isOff("sharpen"), false); }); -test("the settings of a Generate are taken once: frozenInputs is a copy, wired inputs are left to their node", async () => { - const PS = load([]); - await PS.ensureLoaded(); - const frozen = PS.frozenInputs(); - assert.deepStrictEqual(frozen.model, { file: "", dtype: "bf16" }); - assert.deepStrictEqual(frozen.gen, { steps: 8 }); // `model: ["model", 0]` is fed by a node - await PS.save({ inputs: { gen: { steps: 4 } } }); - assert.strictEqual(PS.slots().find((s) => s.id === "gen").inputs.steps, 4); - assert.strictEqual(frozen.gen.steps, 8); // the snapshot does not follow the edit - frozen.model.dtype = "fp16"; - assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.dtype, "bf16"); // and is not the live object -}); - test("a pipeline replaced whole (a preset) is reopened as it was: no default slot or role comes back", async () => { const PS = load([]); await PS.ensureLoaded();