fix: Generate waits for Story text still being split; Chat says when no enhancer is there; node dots match the banner; queue refusals name the scene; drop unused frozenInputs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a784bed919
commit
9f8d8034e1
+1
-1
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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")));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user