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:
digital-garbage
2026-10-06 02:04:19 +03:00
co-authored by Claude Opus 5.5
parent a784bed919
commit 9f8d8034e1
10 changed files with 61 additions and 43 deletions
+1 -1
View File
@@ -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 });
+5 -1
View File
@@ -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;
+13
View File
@@ -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"));
});
+7 -2
View File
@@ -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),
+15 -8
View File
@@ -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"]);
});
+3 -1
View File
@@ -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
+4 -3
View File
@@ -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];
+13
View File
@@ -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")));
});
-14
View File
@@ -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,
-13
View File
@@ -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();