diff --git a/app/boot.js b/app/boot.js index 11291c28..85eb0668 100644 --- a/app/boot.js +++ b/app/boot.js @@ -71,6 +71,24 @@ async function start() { onGenerate: () => { ranFor = project.selectedId; session.generate(); }, onCancel: () => run.cancel(), onConstructor: () => page.constructor.open(), + projects: () => project.recent, + currentProject: () => (project.project ? project.project.id : null), + onProject: async (id) => { + if (id === "new") { + const asked = composer.modal.prompt({ + title: "New project", label: "Name", value: "Untitled", confirmLabel: "Create", + }); + const name = await asked.result; + if (name === null) return; // cancelled + await project.newProject(name); + } else if (id !== (project.project || {}).id) { + await project.open(id); + } else { + return; // already open + } + // The prompt box follows the project, the same way it follows a scene. + showScene(project.selected); + }, onPipeline: () => openPipeline({ load, describe, check, search, onApply: (next) => { @@ -97,7 +115,12 @@ async function start() { // Saved on the way out. A debounce that has not fired yet is work the user // did and cannot see anywhere, and a reload is exactly when it is lost. - window.addEventListener("pagehide", () => { project.flush(); }); + window.addEventListener("pagehide", () => { + // Blur first: a control commits on blur, so text still being typed is not + // in the project yet -- and this is exactly the moment it would be lost. + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); + project.flush(); + }); let manifest; try { @@ -118,7 +141,10 @@ async function start() { // After the modules, because a region has to exist before anything can be put // in it, and a module may be sharing the region a role names. try { - prompts = createPrompts((await load()).slots, { + prompts = await createPrompts((await load()).slots, { + // Controls come from the node's own description: a combo gets the node's + // real choices, a number its real bounds. + describe, // Typing in the box writes to the scene it belongs to. Without this the // prompt is a value the run uses and the project never hears about, so a // reload shows a timeline whose scenes are all empty. diff --git a/app/composer/elements/input.js b/app/composer/elements/input.js index ac506391..64432ec8 100644 --- a/app/composer/elements/input.js +++ b/app/composer/elements/input.js @@ -14,7 +14,16 @@ function wire(node, { onInput, onCommit }) { let last = node.value; const commit = () => { if (node.value !== last) { last = node.value; onCommit(node.value); } }; node.addEventListener("blur", commit); - node.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } }); + // And `change`, which is what autofill, a password manager and a script + // setting the value all fire without ever taking focus. commit() only acts + // on a real difference, so the pair costs nothing when both arrive. + node.addEventListener("change", commit); + // Enter commits a one-line field. In a TEXTAREA it is a newline, and + // stealing it there means a multi-line value cannot be typed at all -- the + // prompt included, which is the longest multi-line thing in the app. + if (node.tagName !== "TEXTAREA") { + node.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } }); + } } } diff --git a/app/composer/tests/elements_behaviour.test.js b/app/composer/tests/elements_behaviour.test.js index e0f48c0a..e0fd9ac2 100644 --- a/app/composer/tests/elements_behaviour.test.js +++ b/app/composer/tests/elements_behaviour.test.js @@ -468,3 +468,20 @@ test("a bin's thumbnails are lazy", () => { g.destroy(); } }); + +test("Enter is a newline in a textarea, not a commit", () => { + // A one-line field commits on Enter. A textarea holds the longest thing + // anyone types here -- the prompt -- and stealing Enter there means a + // multi-line value cannot be typed at all. + const committed = []; + const box = mount(composer.textarea.md({ label: "Prompt", onCommit: (v) => committed.push(v) })); + box.node.value = "first line"; + const event = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + box.node.dispatchEvent(event); + + assert.equal(event.defaultPrevented, false, "Enter was swallowed, so no second line can be typed"); + assert.deepEqual(committed, []); + + fire(box.node, "blur"); + assert.deepEqual(committed, ["first line"], "blur is what commits a textarea"); +}); diff --git a/app/shell/layout.js b/app/shell/layout.js index 0bdc83da..4f764ac7 100644 --- a/app/shell/layout.js +++ b/app/shell/layout.js @@ -176,7 +176,13 @@ export function build(root, handlers = {}) { // The menu bar: who this is, what is not a zone, and whether ComfyUI is // still on the other end. A zone head holds what acts on THAT zone, so // anything that acts on the app has nowhere else to live. - const menubar = createMenubar({ workspace, onPipeline: handlers.onPipeline }); + const menubar = createMenubar({ + workspace, + onPipeline: handlers.onPipeline, + onProject: handlers.onProject, + projects: handlers.projects, + current: handlers.currentProject, + }); const bar = menubar; const page = composer.frame.app({ header: bar, main: workspace }); diff --git a/app/shell/menubar.js b/app/shell/menubar.js index 289947f8..7ba3d8a4 100644 --- a/app/shell/menubar.js +++ b/app/shell/menubar.js @@ -69,9 +69,18 @@ function menu(label, itemsOf, onPick) { return button; } -export function createMenubar({ workspace, onPipeline, theme = services.theme } = {}) { +export function createMenubar({ workspace, onPipeline, onProject, projects = () => [], + current = () => null, theme = services.theme } = {}) { const connection = createConnection(); + const file = menu("File", () => [ + { id: "new", label: "New project…" }, + ...(projects().length ? [{ separator: true }] : []), + // A tick rather than leaving the current one out: which project is open is + // a fact about the app, and a menu that hides it makes you press one to find out. + ...projects().map((p) => ({ id: p.id, label: p.name, icon: p.id === current() ? "✓" : " " })), + ], (id) => { if (onProject) onProject(id); }); + const view = menu("View", () => [ { id: "left", label: `${workspace && workspace.isOpen("left") ? "Hide" : "Show"} Assets` }, { id: "right", label: `${workspace && workspace.isOpen("right") ? "Hide" : "Show"} Properties` }, @@ -93,9 +102,9 @@ export function createMenubar({ workspace, onPipeline, theme = services.theme } const bar = composer.toolbar.default({ label: "FunPack", - items: [composer.brand.default({ name: "FunPack" }), view, settings], + items: [composer.brand.default({ name: "FunPack" }), file, view, settings], trailing: connection.items, }); - return { node: bar.node, bar, connection, view, settings }; + return { node: bar.node, bar, connection, file, view, settings }; } diff --git a/app/shell/projects.js b/app/shell/projects.js index eccdc09f..417ad236 100644 --- a/app/shell/projects.js +++ b/app/shell/projects.js @@ -56,6 +56,10 @@ const put = (project) => json("PUT", `${BASE}/${encodeURIComponent(project.id)}` export function createProject({ onChange, onError } = {}) { let project = null; let selected = null; + // Every project this browser knows about, for the File menu. Read once at + // startup and kept in step here rather than re-fetched each time the menu + // opens: a listing is not a thing that changes behind the user's back. + let recent = []; let timer = null; let saving = null; // the PUT in flight, so a queued one waits for it let dirty = false; @@ -97,9 +101,13 @@ export function createProject({ onChange, onError } = {}) { get selected() { return sceneAt(selected); }, /** The most recent project, or a new one when there are none. */ + get recent() { return recent; }, + async start() { const found = await list(); + recent = found; project = found.length ? await read(found[0].id) : await create("Untitled"); + if (!found.length) recent = [{ id: project.id, name: project.name }]; selected = (project.scenes || [])[0]?.id ?? null; changed(); return project; @@ -116,6 +124,7 @@ export function createProject({ onChange, onError } = {}) { async newProject(name) { await flush(); project = await create(name); + recent = [{ id: project.id, name: project.name }, ...recent]; selected = (project.scenes || [])[0]?.id ?? null; changed(); return project; diff --git a/app/shell/prompt.js b/app/shell/prompt.js index 84f0762c..f01784a7 100644 --- a/app/shell/prompt.js +++ b/app/shell/prompt.js @@ -3,87 +3,102 @@ // The prompt is a slot input like any other -- `positive.text` on a // CLIPTextEncode -- and until this existed the only way to type one was to open // the pipeline window, find the node and edit it there. That is a graph editor, -// not an app. +// not an app. The same mechanism now carries the size a project generates at +// and the sampler dials, because they are slot inputs too. // // Nothing here knows what a prompt IS. A slot says "this input of mine belongs // at generation.prompt, call it Prompt", the shell either offers a region with // that name or does not, and a role naming a place nobody offers is simply not // shown -- the same rule a module's panel lives by. +// +// The CONTROL comes from the node's own description, through the same +// settingFor/rendererFor pair the pipeline window uses. That is what makes a +// combo a dropdown of the node's real choices and a number obey the node's real +// bounds, without this file knowing that samplers or schedulers exist. import { composer } from "../composer/composer.js"; +import { rendererFor, rendererNameFor, SELF_LABELLING } from "../composer/panel.js"; +import { settingFor } from "./widgets.js"; import { hostFor } from "./mounts.js"; const isLink = (value) => Array.isArray(value); /** - * createPrompts(slots) -> { overrides, sync, fields, destroy } + * createPrompts(slots, { describe }) -> { overrides, sync, fields, at, destroy } * * `overrides` is what the app is holding, addressed by slot and input, ready to * be sent with a run. It is not a copy of the pipeline: the pipeline window * owns the structure, and these own one value each inside it. */ -export function createPrompts(slots = [], { onChange } = {}) { +export async function createPrompts(slots = [], { describe, onChange } = {}) { let fields = []; - function build(from) { + const wanted = (from) => (from || []).flatMap((slot) => + (slot.roles || []).map((role) => ({ slot, role })).filter(({ role }) => hostFor(role.at))); + + async function build(from) { // The FIELD, not the control inside it: destroying the control leaves the // label and the box it sat in behind, which is a labelled empty space. for (const field of fields) field.field.destroy(); fields = []; - for (const slot of from || []) { - for (const role of slot.roles || []) { - const host = hostFor(role.at); - if (!host) continue; // nobody offers that place + const asked = wanted(from); + if (!asked.length) return; + const described = describe + ? await describe([...new Set(asked.map(({ slot }) => slot.node))]) + : {}; - const current = (slot.inputs || {})[role.input]; - // A wired input is fed by another node, and a box over one would offer - // to write a value the server refuses. Nothing is said: an input that - // cannot be typed into has no control, which is the same answer the - // pipeline window gives for a socket. - if (isLink(current)) continue; - const kind = current === undefined ? "string" : typeof current; - if (kind !== "string" && kind !== "number") continue; + for (const { slot, role } of asked) { + const current = (slot.inputs || {})[role.input]; + // A wired input is fed by another node, and a box over one would offer to + // write a value the server refuses. Nothing is said: an input that cannot + // be typed into has no control, which is the same answer the pipeline + // window gives for a socket. + if (isLink(current)) continue; - // The field exists before its control so the control can report which - // field changed. Two places send values now -- a scene's text and the - // project's own settings -- and "something changed, go and read it all" - // does not say which one. - const entry = { slot: slot.id, input: role.input, at: role.at }; - const told = () => { if (onChange) onChange(entry); }; + const node = described[slot.node]; + const widget = ((node && node.widgets) || []).find((w) => w.name === role.input); + // No description, or nothing sane to draw for it: absent, not a guess. + const setting = widget && settingFor(widget); + if (!setting) continue; - entry.control = kind === "number" - // No min or max: what they are is the NODE's business, and it already - // refuses a value outside them with a reason, beside Generate. - ? composer.number.md({ value: current, onChange: told }) - : composer.textarea.md({ - // No label here: the field around it draws one and points at it, - // and an aria-label as well would win over the visible words. - value: current || "", - rows: 2, - autoGrow: true, - placeholder: role.label || role.input, - onInput: told, - }); - entry.field = composer.field.default({ - label: role.label || role.input, - control: entry.control, - }); - host.appendChild(entry.field.node); - fields.push(entry); - } + const entry = { slot: slot.id, input: role.input, at: role.at, + value: current === undefined ? setting.default : current }; + const told = (next) => { + entry.value = next; + if (onChange) onChange(entry); + }; + entry.control = rendererFor(setting)({ ...setting, label: role.label || setting.label }, + entry.value, told); + // A checkbox row draws its own label and hint; wrapping it in a field + // would print both of them twice, one above the other. + entry.field = SELF_LABELLING.has(rendererNameFor(setting)) + ? entry.control + : composer.field.default({ label: role.label || setting.label, + hint: setting.hint, control: entry.control }); + hostFor(role.at).appendChild(entry.field.node); + fields.push(entry); } } - build(slots); + /** What a caller holds: the value, and a way to set it that both halves see. */ + const handleFor = (entry) => ({ + get value() { return entry.value; }, + setValue(next) { + entry.value = next; + if (entry.control.setValue) entry.control.setValue(next); + }, + }); + + await build(slots); return { - /** {slotId: {input: value}} -- only what a person can actually have typed. */ + /** {slotId: {input: value}} -- only what a person can actually have set. */ overrides() { const out = {}; - for (const { slot, input, control } of fields) { + for (const { slot, input, value } of fields) { out[slot] = out[slot] || {}; - out[slot][input] = control.value; + out[slot][input] = value; } return out; }, @@ -91,32 +106,31 @@ export function createPrompts(slots = [], { onChange } = {}) { /** * The pipeline changed underneath: rebuild against it. * - * A slot the pipeline window removed takes its box with it, and a value - * saved in that window is what the box then shows. Keeping the old value - * would make the two windows disagree about one input, and the last one - * sent would win silently. + * A slot the pipeline window removed takes its control with it, and a value + * saved in that window is what the control then shows. Keeping the old value + * would make the two windows disagree about one input, and the last one sent + * would win silently. */ - sync(next) { build(next); }, + sync(next) { return build(next); }, get fields() { return fields.map((f) => ({ slot: f.slot, input: f.input, at: f.at })); }, /** - * The control mounted at a place, or null. - * - * What makes a scene's text editable: the timeline owns the text and this is - * the box it is typed in, so the two are bound rather than each holding - * their own copy of a prompt. + * The control at a place, or null. What makes a scene's text editable: the + * timeline owns the text and this is where it is typed, so the two are bound + * rather than each holding their own copy of a prompt. */ at(point) { const found = fields.find((f) => f.at === point); - return found ? found.control : null; + return found ? handleFor(found) : null; }, /** Every control at a place, by the input it writes. */ controlsAt(point) { return fields.filter((f) => f.at === point) - .map(({ input, control }) => ({ input, control })); + .map((f) => ({ input: f.input, control: handleFor(f) })); }, - destroy() { build([]); }, + + destroy() { return build([]); }, }; } diff --git a/app/shell/tests/prompt.test.js b/app/shell/tests/prompt.test.js index b9b7141d..fe986a58 100644 --- a/app/shell/tests/prompt.test.js +++ b/app/shell/tests/prompt.test.js @@ -14,6 +14,22 @@ test.before(async () => { }); test.after(() => teardownDom()); +// What the server says a node's inputs look like. The control a role gets comes +// from HERE, not from a guess about the value, which is what makes a combo a +// dropdown of the node's own choices. +const NODES = { + CLIPTextEncode: { widgets: [{ name: "text", type: "STRING", multiline: true, default: "" }] }, + EmptyLatent: { widgets: [ + { name: "width", type: "INT", default: 512, min: 16, max: 4096, step: 8 }, + { name: "length", type: "INT", default: 1, min: 1, max: 512 }, + ] }, + Sampler: { widgets: [ + { name: "sampler_name", type: "COMBO", choices: ["euler", "dpmpp_2m"], default: "euler" }, + ] }, +}; +const describe = async (classes) => + Object.fromEntries(classes.map((c) => [c, NODES[c] || null])); + const SLOTS = [ { id: "positive", node: "CLIPTextEncode", roles: [{ at: "generation.prompt", input: "text", label: "Prompt" }], @@ -32,25 +48,28 @@ function host(mount = "generation.prompt") { return node; } +const make = (slots, opts = {}) => createPrompts(slots, { describe, ...opts }); const boxes = (node) => [...node.querySelectorAll("textarea")]; -test("a slot that names a place gets a box there", () => { +test("a slot that names a place gets a control there", async () => { const node = host(); - const p = createPrompts(SLOTS); + const p = await make(SLOTS); assert.equal(boxes(node).length, 2); assert.deepEqual([...node.querySelectorAll(".cx-label")].map((l) => l.textContent), ["Prompt", "Negative"]); - assert.equal(boxes(node)[0].value, "a cat", "the box did not show what the slot holds"); + assert.equal(boxes(node)[0].value, "a cat", "the control did not show what the slot holds"); assert.deepEqual(p.fields.map((f) => f.slot), ["positive", "negative"]); }); -test("what is typed comes back addressed by slot and input", () => { +test("what is typed comes back addressed by slot and input", async () => { const node = host(); - const p = createPrompts(SLOTS); + const p = await make(SLOTS); + // Committed, not per keystroke: the control is the node's own, and the node's + // own control commits on blur. boot blurs before it saves for the same reason. boxes(node)[0].value = "a dog on a roof"; - fire(boxes(node)[0], "input"); + fire(boxes(node)[0], "blur"); assert.deepEqual(p.overrides(), { positive: { text: "a dog on a roof" }, @@ -58,29 +77,43 @@ test("what is typed comes back addressed by slot and input", () => { }); }); -test("a numeric input gets a number control, not a prompt box", () => { - // The same mechanism carries the prompt and the size the project generates - // at. What kind of control appears is decided by what is IN the input, so a - // pipeline that puts a number on the surface does not get a textarea for it. +test("the control is the node's, so a combo is its own list of choices", async () => { + // The whole reason this asks the server what a node looks like: a dropdown of + // the real samplers, and a number that obeys the real bounds, without this + // file knowing that samplers exist. + const node = host("generation.sampling"); + const p = await make([{ id: "sampler", node: "Sampler", + roles: [{ at: "generation.sampling", input: "sampler_name", label: "Sampler" }], + inputs: { sampler_name: "dpmpp_2m" } }]); + + const select = node.querySelector("select"); + assert.ok(select, "a combo did not get a dropdown"); + assert.deepEqual([...select.options].map((o) => o.value), ["euler", "dpmpp_2m"]); + assert.equal(select.value, "dpmpp_2m", "the dropdown did not show the slot's value"); + + select.value = "euler"; + fire(select, "change"); + assert.deepEqual(p.overrides(), { sampler: { sampler_name: "euler" } }); +}); + +test("a numeric input gets a number control, not a prompt box", async () => { const node = host("project.video"); - const p = createPrompts([{ id: "latent", node: "EmptyLatent", + const p = await make([{ id: "latent", node: "EmptyLatent", roles: [{ at: "project.video", input: "width", label: "Width" }, { at: "project.video", input: "length", label: "Length" }], inputs: { width: 832, length: 97 } }]); - assert.equal(node.querySelectorAll("textarea").length, 0); - const numbers = [...node.querySelectorAll('input[type="number"]')]; - assert.equal(numbers.length, 2); - assert.equal(numbers[0].value, "832"); + assert.equal(boxes(node).length, 0); + assert.equal(node.querySelectorAll('input[type="number"]').length, 2); assert.deepEqual(p.overrides(), { latent: { width: 832, length: 97 } }); }); -test("a changed control says which input it was", () => { - // Two places take a value now -- a scene's text and the project's settings -- - // and "something changed, go and read it all" does not say which one. +test("a changed control says which input it was", async () => { + // Two places take a value -- a scene's text and the project's settings -- and + // "something changed, go and read it all" does not say which one. const node = host("project.video"); const told = []; - createPrompts([{ id: "latent", node: "EmptyLatent", + await make([{ id: "latent", node: "EmptyLatent", roles: [{ at: "project.video", input: "width", label: "Width" }], inputs: { width: 512 } }], { onChange: (field) => told.push(field) }); @@ -92,86 +125,93 @@ test("a changed control says which input it was", () => { assert.equal(told[0].at, "project.video"); assert.equal(told[0].input, "width"); assert.equal(told[0].slot, "latent"); - assert.equal(told[0].control.value, 832); + assert.equal(told[0].value, 832); }); -test("the controls at a place come back with the input each one writes", () => { +test("setting a control's value is what the run then sends", async () => { + // The box and the value behind it are one thing. Seeded from the project on + // open and from the scene on selection, and either would be a lie if the + // control moved and the value did not. const node = host("project.video"); - const p = createPrompts([{ id: "latent", node: "EmptyLatent", + const p = await make([{ id: "latent", node: "EmptyLatent", roles: [{ at: "project.video", input: "width", label: "Width" }, - { at: "project.video", input: "height", label: "Height" }], - inputs: { width: 512, height: 512 } }]); - void node; + { at: "project.video", input: "length", label: "Length" }], + inputs: { width: 512, length: 1 } }]); const found = p.controlsAt("project.video"); - assert.deepEqual(found.map((f) => f.input), ["width", "height"]); - found[1].control.setValue(480); - assert.deepEqual(p.overrides(), { latent: { width: 512, height: 480 } }); + assert.deepEqual(found.map((f) => f.input), ["width", "length"]); + found[0].control.setValue(640); + + assert.equal(found[0].control.value, 640); + assert.equal(node.querySelector('input[type="number"]').value, "640"); + assert.deepEqual(p.overrides(), { latent: { width: 640, length: 1 } }); assert.deepEqual(p.controlsAt("nowhere.at.all"), []); }); -test("a role naming a place nobody offers is not shown", () => { +test("a role naming a place nobody offers is not shown", async () => { // The same rule a module's panel lives by: absent, not broken, and not a // warning either -- this shell may simply not have that region yet. const node = host("somewhere.else"); - const p = createPrompts(SLOTS); + const p = await make(SLOTS); assert.equal(boxes(node).length, 0); assert.deepEqual(p.overrides(), {}); }); -test("an input fed by another node gets no box", () => { +test("an input fed by another node gets no control", async () => { // A value written over a link unwires a node, and the server refuses it. A - // box offering to do that is a box whose every use is an error. + // control offering to do that is one whose every use is an error. const node = host(); - createPrompts([{ id: "positive", node: "CLIPTextEncode", + await make([{ id: "positive", node: "CLIPTextEncode", roles: [{ at: "generation.prompt", input: "clip", label: "CLIP" }], inputs: { clip: ["clip", 0] } }]); - assert.equal(boxes(node).length, 0); + assert.equal(node.children.length, 0); }); -test("an input holding something other than text gets no box", () => { +test("an input the node does not describe gets no control", async () => { + // A pipeline can name an input a node has not got -- after a node swap, or a + // hand-edited pipeline. Nothing sane to draw means nothing drawn. const node = host(); - createPrompts([{ id: "latent", node: "EmptyLatent", - roles: [{ at: "generation.prompt", input: "width", label: "Width" }], - inputs: { width: 512 } }]); - assert.equal(boxes(node).length, 0, "a number was offered a prompt box"); + await make([{ id: "positive", node: "CLIPTextEncode", + roles: [{ at: "generation.prompt", input: "gone", label: "Gone" }], + inputs: {} }]); + assert.equal(node.children.length, 0); }); -test("a slot with no roles puts nothing anywhere", () => { +test("a slot with no roles puts nothing anywhere", async () => { const node = host(); - createPrompts([{ id: "latent", node: "EmptyLatent", inputs: { width: 512 } }]); - assert.equal(boxes(node).length, 0); + await make([{ id: "latent", node: "EmptyLatent", inputs: { width: 512 } }]); + assert.equal(node.children.length, 0); }); -test("the pipeline changing underneath takes the boxes with it", () => { - // The pipeline window can remove the slot a box belongs to. A box left behind - // sends a value for a slot that is gone, and the server refuses the whole run - // over a control the user has no way to find. +test("the pipeline changing underneath takes the controls with it", async () => { + // The pipeline window can remove the slot a control belongs to. One left + // behind sends a value for a slot that is gone, and the server refuses the + // whole run over a control the user has no way to find. const node = host(); - const p = createPrompts(SLOTS); + const p = await make(SLOTS); boxes(node)[0].value = "a dog"; - fire(boxes(node)[0], "input"); + fire(boxes(node)[0], "blur"); - p.sync(SLOTS.filter((s) => s.id !== "positive")); + await p.sync(SLOTS.filter((s) => s.id !== "positive")); assert.equal(boxes(node).length, 1); assert.deepEqual(p.overrides(), { negative: { text: "" } }); }); -test("a value saved in the pipeline window is what the box then shows", () => { +test("a value saved in the pipeline window is what the control then shows", async () => { const node = host(); - const p = createPrompts(SLOTS); + const p = await make(SLOTS); boxes(node)[0].value = "typed here"; - fire(boxes(node)[0], "input"); + fire(boxes(node)[0], "blur"); - p.sync([{ ...SLOTS[0], inputs: { clip: ["clip", 0], text: "saved there" } }]); + await p.sync([{ ...SLOTS[0], inputs: { clip: ["clip", 0], text: "saved there" } }]); assert.equal(boxes(node)[0].value, "saved there", "two windows held different text for one input"); assert.deepEqual(p.overrides(), { positive: { text: "saved there" } }); }); -test("rebuilding leaves nothing behind", () => { +test("rebuilding leaves nothing behind", async () => { const node = host(); - const p = createPrompts(SLOTS); - p.sync([]); + const p = await make(SLOTS); + await p.sync([]); assert.equal(node.children.length, 0, "a labelled empty space was left behind"); }); diff --git a/app/tests-layout/project_video.spec.js b/app/tests-layout/project_video.spec.js index 4507a877..c794ebb1 100644 --- a/app/tests-layout/project_video.spec.js +++ b/app/tests-layout/project_video.spec.js @@ -43,10 +43,16 @@ test("it is still what the project generates at on the next visit", async ({ pag await openConstructor(page); await widthBox(page).fill("640"); await widthBox(page).blur(); - await page.evaluate(() => window.FunPack.project.flush()); + const id = await page.evaluate(async () => { + await window.FunPack.project.flush(); + return window.FunPack.project.project.id; + }); await page.reload(); await page.waitForFunction(() => window.FunPack !== undefined); + // Opened by id: another test in this file makes projects, and which one a + // fresh page picks is "the most recent", not "the one this test used". + await page.evaluate((pid) => window.FunPack.project.open(pid), id); await expect.poll(() => page.evaluate(() => window.FunPack.project.video.width)).toBe(640); await openConstructor(page); @@ -81,3 +87,29 @@ test("what a run produces lands on the scene it was started for", async ({ page // 404 for every /view, so the strip shows its glyph -- which is the fallback // doing its job, not a missing result. }); + +test("a project can be made and switched to from the File menu", async ({ page }) => { + // The store could do all of this from the day it arrived; there was no way in. + await page.goto("/funpack/"); + await page.waitForFunction(() => window.FunPack !== undefined); + + // A name of its own: the dev server keeps real project files, so what is + // already in the store is whatever earlier runs left there. + const name = `Made ${Date.now()}`; + await page.getByRole("button", { name: "File" }).click(); + await page.getByRole("menuitem", { name: /New project/ }).click(); + await page.locator(".cx-modal input").fill(name); + await page.locator(".cx-modal").getByRole("button", { name: "Create" }).click(); + + await expect.poll(() => page.evaluate(() => window.FunPack.project.project.name)).toBe(name); + const made = await page.evaluate(() => window.FunPack.project.project.id); + + // And away again. By position, not by name: every other project in the store + // may well be called Untitled. + await page.getByRole("button", { name: "File" }).click(); + const items = page.getByRole("menuitem"); + await expect(items.nth(1)).toHaveText(new RegExp(name)); // the one just made, ticked + await items.nth(2).click(); + + await expect.poll(() => page.evaluate(() => window.FunPack.project.project.id)).not.toBe(made); +}); diff --git a/app/tests-layout/prompt.spec.js b/app/tests-layout/prompt.spec.js index d05fd59a..c40f618a 100644 --- a/app/tests-layout/prompt.spec.js +++ b/app/tests-layout/prompt.spec.js @@ -21,6 +21,7 @@ test("the prompt is written in the Constructor, and typing in it is what runs", const boxes = page.locator(".cx-modal textarea"); await expect(boxes).toHaveCount(2); await boxes.first().fill("a cat on a rooftop"); + await boxes.first().blur(); // a control commits on blur const sent = await page.evaluate(async () => { const body = JSON.stringify({ inputs: window.FunPack.prompts() }); @@ -40,6 +41,7 @@ test("what was typed is still there the next time the window opens", async ({ pa await openConstructor(page); await page.locator(".cx-modal textarea").first().fill("a cat on a rooftop"); + await page.locator(".cx-modal textarea").first().blur(); await page.locator(".cx-modal").getByRole("button", { name: "Done" }).click(); await expect(page.locator(".cx-modal")).toHaveCount(0); diff --git a/core/tests/test_routes_pipeline.py b/core/tests/test_routes_pipeline.py index 845789b8..edcb3202 100644 --- a/core/tests/test_routes_pipeline.py +++ b/core/tests/test_routes_pipeline.py @@ -437,5 +437,5 @@ def test_the_default_pipeline_says_where_its_prompt_belongs(server): roles = [(s["id"], r) for s in body["slots"] for r in (s.get("roles") or [])] assert roles, "no slot offers anything to the main window" places = {r["at"] for _id, r in roles} - assert places == {"generation.prompt", "project.video"} + assert places == {"generation.prompt", "project.video", "generation.sampling"} assert all(r["input"] and r["label"] for _id, r in roles) diff --git a/modules/system/pipeline/__init__.py b/modules/system/pipeline/__init__.py index d3691e05..58198ac3 100644 --- a/modules/system/pipeline/__init__.py +++ b/modules/system/pipeline/__init__.py @@ -57,7 +57,14 @@ DEFAULT = [ {"id": "modifiers", "group": "Preparation", "node": "FunPackLoadModifiers", "inputs": { "model": ["model", 0], "settings": ["settings", 0]}}, - {"id": "sampler", "group": "Sampling", "node": "FunPackSampler", "inputs": { + {"id": "sampler", "group": "Sampling", "node": "FunPackSampler", + # The three dials that get reached for constantly. v4 buried them several + # clicks deep in Engine Settings; here they are on the main window because + # the slot says they belong there. + "roles": [{"at": "generation.sampling", "input": "steps", "label": "Steps"}, + {"at": "generation.sampling", "input": "sampler_name", "label": "Sampler"}, + {"at": "generation.sampling", "input": "scheduler", "label": "Scheduler"}], + "inputs": { "model": ["modifiers", 0], "positive": ["positive", 0], "negative": ["negative", 0], "latent": ["latent", 0], "settings": ["settings", 0], "seed": 0, "steps": 20, "cfg": 7.0,