fix: a pipeline retried after a failed first load no longer deadlocks; Undo keeps a pipeline edit in flight and skips needless reloads; opening a project never rewrites it; Enhance follows the open project; readiness names the GPU ComfyUI uses; Sampler says when it cannot read its options
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef84df4286
commit
f423f84a30
@@ -47,6 +47,8 @@ export function enhance(app, own) {
|
||||
}
|
||||
draw();
|
||||
ps.ensureLoaded().then(draw);
|
||||
// Another project's pipeline, or an edit made elsewhere: redraw, but never under a field being typed in.
|
||||
own(ps.subscribe(() => { if (!page.node.contains(document.activeElement)) draw(); }));
|
||||
app.api.enhancerDefaults().then((d) => { defaults = d; draw(); }).catch(() => {});
|
||||
app.api.enhancerRuns().then((r) => { last = (r.runs || []).slice(-1)[0] || null; draw(); }).catch(() => {});
|
||||
return page;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
||||
|
||||
let enhance;
|
||||
test.before(async () => { setupDom(); ({ enhance } = await import("./enhance.js")); });
|
||||
test.after(() => teardownDom());
|
||||
|
||||
test("the Enhance tab follows the open project's pipeline: no enhancer there, no toggle shown", async () => {
|
||||
let live = [{ id: "e", node: "FunPackEnhancePrompt", inputs: { enabled: true } }], heard;
|
||||
const ps = { slots: () => live, ensureLoaded: async () => {}, save: async () => {}, subscribe: (fn) => { heard = fn; return () => {}; } };
|
||||
const api = { enhancerDefaults: async () => ({}), enhancerRuns: async () => ({ runs: [] }) };
|
||||
const page = enhance({ pipeline: ps, api }, () => {});
|
||||
document.body.append(page.node);
|
||||
assert.match(page.node.textContent, /Enhance prompt/);
|
||||
live = [{ id: "s", node: "FunPackSampler", inputs: {} }]; // another project opened
|
||||
heard(live);
|
||||
assert.match(page.node.textContent, /No prompt enhancer here/);
|
||||
});
|
||||
@@ -30,3 +30,16 @@ test("the Sampler window shows the pipeline's steps and sampler, and a change sa
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.deepStrictEqual(saved, [{ inputs: { sampler: { sampler_name: "dpmpp_2m" } } }]);
|
||||
});
|
||||
|
||||
test("when the sampler node's options cannot be read, the window says where to change them", async () => {
|
||||
const slot = { id: "sampler", node: "S", inputs: { steps: 20 }, roles: [{ at: "generation.sampling", input: "steps", label: "Steps" }] };
|
||||
const ps = { slots: () => [slot], activeModules: () => [], useful: () => true, currentValues: () => ({}), loading: () => false, loadError: () => null,
|
||||
subscribe: () => () => {}, ensureLoaded: async () => {}, save: async () => {} };
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
hub.setup({ host, app: { pipeline: ps, api: { describeNodes: async () => { throw new Error("offline"); } }, on: () => () => {}, actions: null } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
host.querySelector("button").click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.match(document.body.textContent, /could not be read from the sampler node/);
|
||||
});
|
||||
|
||||
@@ -12,12 +12,12 @@ export default {
|
||||
needs: ["pipeline"],
|
||||
setup({ host, app }) {
|
||||
const ps = app.pipeline;
|
||||
let pop = null, specs = {};
|
||||
let pop = null, specs = {}, unreadable = false;
|
||||
const sampling = () => ps.activeModules().filter((m) => (m.category || "") === "sampling" && Object.keys(m.settings || {}).length && ps.useful(m));
|
||||
const marked = () => (ps.slots() || []).flatMap((slot) => (slot.roles || []).filter((r) => r.at === "generation.sampling" && r.input && !Array.isArray((slot.inputs || {})[r.input])).map((role) => ({ slot, role })));
|
||||
const describe = async () => {
|
||||
const missing = [...new Set(marked().map(({ slot }) => slot.node))].filter((n) => !(n in specs));
|
||||
if (missing.length) try { Object.assign(specs, (await app.api.describeNodes(missing)).nodes || {}); } catch { /* the rows just stay out until the next change */ }
|
||||
if (missing.length) try { Object.assign(specs, (await app.api.describeNodes(missing)).nodes || {}); unreadable = false; } catch { unreadable = true; } // asked again on the next change
|
||||
};
|
||||
const inputRow = ({ slot, role }) => {
|
||||
const w = ((specs[slot.node] || {}).widgets || []).find((x) => x.name === role.input);
|
||||
@@ -35,7 +35,9 @@ export default {
|
||||
if (ps.loadError()) return body.set([c.banner.warn({ text: `Could not load: ${ps.loadError()}` })]);
|
||||
const mods = sampling(), values = ps.currentValues();
|
||||
const change = async (id, name, value) => { await ps.setModuleValue(id, name, value); draw(); };
|
||||
body.set([...marked().map(inputRow), ...mods.flatMap((m) => [mods.length > 1 ? c.label.section({ text: m.title || m.id }) : null,
|
||||
const rows = marked().map(inputRow);
|
||||
const unread = marked().length && !rows.some(Boolean) && (unreadable || marked().every(({ slot }) => slot.node in specs)) ? [c.hint.default({ text: "Steps, sampler and scheduler could not be read from the sampler node: change them in Settings ▸ Models & Pipeline." })] : [];
|
||||
body.set([...unread, ...rows, ...mods.flatMap((m) => [mods.length > 1 ? c.label.section({ text: m.title || m.id }) : null,
|
||||
...Object.entries(m.settings).filter(([, spec]) => whenSatisfied(values, m.id, spec.when)).map(([name, spec]) => settingRow(m.id, name, spec, values, change))])].filter(Boolean));
|
||||
};
|
||||
const typing = () => body.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
|
||||
|
||||
@@ -20,7 +20,7 @@ export const shotMemory = (app) => function mount() {
|
||||
if (error) return page.set([c.banner.warn({ text: `Could not read the camera's memory: ${error}` })]);
|
||||
if (!data) return page.set([c.hint.default({ text: "Loading…" })]);
|
||||
const words = data.words || [], views = data.views || [];
|
||||
if (!words.length && !views.length && !(data.arms || []).length) return page.set([c.emptyState.default({ icon: "◎", title: "Nothing learned yet", hint: "Turn on camera moves or shot views, then rate renders: the camera remembers which words and views you liked." })]);
|
||||
if (!words.length && !views.length && !(data.arms || []).length) return page.set([c.emptyState.default({ icon: "◎", title: "Nothing learned yet", hint: "In a pipeline with the Shot camera node, turn on camera moves or shot views in Engine, then rate renders: the camera remembers which words and views you liked." })]);
|
||||
page.set([c.hint.default({ text: `${data.prompts} prompts seen · ${data.shots} shots reviewed. Forget anything that taught it the wrong thing.` }),
|
||||
...(views.length ? [c.label.section({ text: "Views" }), ...views.map((v) => row(v.view, `liked ${v.good.toFixed(1)} · disliked ${v.bad.toFixed(1)}`, "view", v.view))] : []),
|
||||
...(words.length ? [c.label.section({ text: "Words the camera aims at" }), ...words.map((w) => row(w.word, `picked ${w.picks} · replaced ${w.rejects} · seen ${w.seen}`, "word", w.word)),
|
||||
|
||||
@@ -7,18 +7,22 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
|
||||
let owner = null;
|
||||
let retries = 0, opens = 0, retry = null;
|
||||
// Only a real change is written: opening a project must not rewrite its file.
|
||||
const store = (base, slots, opening) => {
|
||||
const next = { ...base, slots: JSON.parse(JSON.stringify(slots || [])), removed: pipeline.removedIds(), unwired: pipeline.unwiredMap(), whole: pipeline.whole() };
|
||||
if (opening && !("whole" in base)) delete next.whole; // an older file is not rewritten just to record the guess; an edit records it
|
||||
const live = (base, slots) => ({ ...base, slots: JSON.parse(JSON.stringify(slots || [])), removed: pipeline.removedIds(), unwired: pipeline.unwiredMap(), whole: pipeline.whole() });
|
||||
const store = (base, slots) => {
|
||||
const next = live(base, slots);
|
||||
if (JSON.stringify(next) !== JSON.stringify(project.project.models || {})) project.setField("models", next, { quiet: true });
|
||||
};
|
||||
|
||||
async function adopt() {
|
||||
clearTimeout(retry); // a retry still waiting is for a project that may no longer be open
|
||||
if (pipeline.settled) await pipeline.settled(); // an edit on its way lands in the project it was made in first
|
||||
const here = project.project && project.project.models;
|
||||
// Undo / Redo of something else: the same project, its pipeline already live, nothing to put in (or to lose).
|
||||
if (owner && project.project && owner === project.project.id && here && pipeline.slots()
|
||||
&& JSON.stringify(live(here, pipeline.slots())) === JSON.stringify(here)) return;
|
||||
owner = null;
|
||||
const mine = ++opens, target = project.project && project.project.id;
|
||||
const saved = project.project && project.project.models || {};
|
||||
const own = (saved.slots || []).length > 0 || (saved.removed || []).length > 0;
|
||||
let ok = false, timer;
|
||||
try {
|
||||
// Bounded: a hung request must not hold the project behind it.
|
||||
@@ -28,8 +32,8 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
|
||||
if (ok && pipeline.slots()) {
|
||||
owner = target;
|
||||
retries = 0;
|
||||
// An old project with nothing saved runs the default and stays as it is on disk; a new one keeps what it took.
|
||||
if (own || project.fresh) store(saved, pipeline.slots(), !project.fresh); // a new project's whole flag is a fact, not a guess
|
||||
// Opening writes nothing: what an update's defaults fill in is laid again on every open. A new project keeps what it took.
|
||||
if (project.fresh) store(saved, pipeline.slots());
|
||||
} else if (retries++ < 3) {
|
||||
retry = setTimeout(adopt, 5000);
|
||||
} else {
|
||||
|
||||
@@ -69,6 +69,7 @@ export function createPipelineState(API) {
|
||||
let removed = new Set(); // default slots the person took out of this project's pipeline
|
||||
let adoptGate = null; // a promise while a project's pipeline is being put in
|
||||
let whole = false; // the pipeline replaced the default (a preset, an import, the wizard) rather than editing it
|
||||
let adopting = 0; // adopts under way (waiting for the load, or putting a pipeline in)
|
||||
let deferred = null; // a project's saved pipeline ([slots, removed, unwired, whole]) waiting for the first successful load
|
||||
let epoch = 0; // bumped whenever a project's pipeline replaces the live one
|
||||
let saving = false;
|
||||
@@ -132,7 +133,8 @@ export function createPipelineState(API) {
|
||||
}
|
||||
loading = false;
|
||||
loadPromise = null;
|
||||
if (slots !== null && deferred) { const d = deferred; deferred = null; await adopt(...d); }
|
||||
// An adopt already under way carries the newest project and lays it itself; waiting on it from here would deadlock.
|
||||
if (slots !== null && deferred) { const d = deferred; deferred = null; if (!adopting) await adopt(...d); }
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
@@ -479,11 +481,12 @@ export function createPipelineState(API) {
|
||||
let open;
|
||||
const gate = new Promise((r) => { open = r; }), before = adoptGate;
|
||||
adoptGate = gate; // before anything awaits: an edit right behind this waits too
|
||||
adopting += 1;
|
||||
try {
|
||||
if (before) await before; // one at a time: a project opened behind another is put in after it, never under it
|
||||
await ensureLoaded();
|
||||
return await _adopt(saved, removedIds, unwiredMap, wholeSaved, inherit);
|
||||
} finally { if (adoptGate === gate) adoptGate = null; open(); _changed(); }
|
||||
} finally { adopting -= 1; if (adoptGate === gate) adoptGate = null; open(); _changed(); }
|
||||
}
|
||||
|
||||
/** Resolves once no edit is on its way and no project's pipeline is going in (at most ~10 s). */
|
||||
|
||||
@@ -108,3 +108,34 @@ test("a retry left over from a project that failed to load never reloads the one
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(calls, before);
|
||||
});
|
||||
|
||||
test("Undo of something else, with a pipeline edit still landing, keeps the edit and puts nothing back in", async () => {
|
||||
const writes = [];
|
||||
let opened, heard, land, adopts = 0;
|
||||
let live = slots;
|
||||
const models = { slots, removed: [], unwired: {}, whole: false };
|
||||
const project = { project: { id: "a", models }, setField: (k, v) => { writes.push(v.slots[0].inputs.ckpt_name); project.project[k] = v; } };
|
||||
const pipeline = { adopt: async () => { adopts += 1; return true; }, slots: () => live, removedIds: () => [], unwiredMap: () => ({}), whole: () => false,
|
||||
subscribe: (fn) => { heard = fn; }, settled: () => new Promise((r) => { land = r; }) };
|
||||
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
|
||||
const first = opened(); land(); await first;
|
||||
const before = adopts;
|
||||
project.project = JSON.parse(JSON.stringify(project.project)); // Undo puts a copy of the project in place
|
||||
const undo = opened();
|
||||
live = [{ ...slots[0], inputs: { ckpt_name: "b" } }]; // the edit's answer arrives
|
||||
heard(live);
|
||||
land();
|
||||
await undo;
|
||||
assert.deepStrictEqual([writes, adopts - before], [["b"], 0]);
|
||||
});
|
||||
|
||||
test("opening an older project whose pipeline an update's defaults fill in does not rewrite it", async () => {
|
||||
const writes = [];
|
||||
let opened;
|
||||
const project = { project: { id: "o", models: { slots, removed: [], unwired: {} } }, setField: (k, v) => { writes.push(v); } };
|
||||
const filled = [{ ...slots[0], inputs: { ...slots[0].inputs, denoise: 1 } }];
|
||||
const pipeline = { adopt: async () => true, slots: () => filled, removedIds: () => [], unwiredMap: () => ({}), whole: () => false, subscribe: () => {} };
|
||||
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
|
||||
await opened();
|
||||
assert.equal(writes.length, 0);
|
||||
});
|
||||
|
||||
@@ -420,3 +420,17 @@ test("a modifier's settings count only when the pipeline has both the settings n
|
||||
assert.deepStrictEqual(await check(["CheckpointLoaderSimple", "KSampler"]), [false, false]); // a plain imported workflow
|
||||
assert.deepStrictEqual(await check(["FunPackModifierSettings", "FunPackLoadModifiers"], [loader, sharpen, taste]), [true, false]); // the key installed, but no learner reads it
|
||||
});
|
||||
|
||||
test("a project retried after the first load failed goes in, and nothing waits forever", async () => {
|
||||
const posts = [];
|
||||
const PS = load(posts);
|
||||
const real = lastApi.pipeline;
|
||||
lastApi.pipeline = async () => { throw new Error("tunnel blip"); };
|
||||
const saved = [{ id: "model", node: "Loader", inputs: { file: "mine" } }];
|
||||
assert.strictEqual(await PS.adopt(saved), false);
|
||||
lastApi.pipeline = real;
|
||||
const done = await Promise.race([PS.adopt(saved), new Promise((r) => setTimeout(() => r("HUNG"), 1000))]);
|
||||
assert.strictEqual(done, true);
|
||||
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "mine");
|
||||
await Promise.race([PS.save({ inputs: { model: { file: "next" } } }), new Promise((_, no) => setTimeout(() => no(new Error("save hung")), 1000))]);
|
||||
});
|
||||
|
||||
+10
-1
@@ -36,6 +36,15 @@ def device() -> str:
|
||||
return "cuda" if torch.cuda.is_available() else "mps" if mps is not None and mps.is_available() else "cpu"
|
||||
|
||||
|
||||
def gpu_index() -> int:
|
||||
"""Which CUDA device ComfyUI uses (a rental may start it with --cuda-device 1)."""
|
||||
try:
|
||||
import comfy.model_management as mm
|
||||
return mm.get_torch_device().index or 0
|
||||
except Exception: # noqa: BLE001 -- not inside ComfyUI
|
||||
return 0
|
||||
|
||||
|
||||
def machine(disk_free_gb=None) -> List[dict]:
|
||||
out = []
|
||||
for tool, why in (("ffmpeg", "the final render and previews"), ("ffprobe", "reading clip lengths")):
|
||||
@@ -51,7 +60,7 @@ def machine(disk_free_gb=None) -> List[dict]:
|
||||
elif kind != "cuda":
|
||||
out.append(row("warn", f"ComfyUI generates on a '{kind}' device, which FunPack is not tested on: expect slow runs, and some features may fail."))
|
||||
else:
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
props = torch.cuda.get_device_properties(gpu_index())
|
||||
out.append(row("ok", f"{props.name}, {props.total_memory / 1024 ** 3:.0f} GB, sm_{props.major}{props.minor}"))
|
||||
if not torch.cuda.is_bf16_supported():
|
||||
out.append(row("fail", "This GPU does not support bf16, which the H3 VAEs need."))
|
||||
|
||||
@@ -42,3 +42,16 @@ def test_the_row_names_the_device_comfyui_generates_on(monkeypatch):
|
||||
monkeypatch.setattr(r, "device", lambda: "xpu") # an Intel GPU is not the CPU
|
||||
rows = r.machine()
|
||||
assert any("'xpu'" in x["text"] for x in rows) and not any("on the CPU" in x["text"] for x in rows)
|
||||
|
||||
|
||||
def test_the_gpu_row_names_the_card_comfyui_uses(monkeypatch):
|
||||
import types
|
||||
import torch
|
||||
asked = []
|
||||
monkeypatch.setattr(r, "device", lambda: "cuda")
|
||||
monkeypatch.setattr(r, "gpu_index", lambda: 1)
|
||||
monkeypatch.setattr(torch.cuda, "get_device_properties",
|
||||
lambda i: asked.append(i) or types.SimpleNamespace(name=f"GPU{i}", total_memory=32 * 1024 ** 3, major=12, minor=0))
|
||||
monkeypatch.setattr(torch.cuda, "is_bf16_supported", lambda: True)
|
||||
rows = r.machine()
|
||||
assert asked == [1] and any("GPU1" in x["text"] for x in rows)
|
||||
|
||||
Reference in New Issue
Block a user