fix: a new project keeps an inherited preset pipeline whole; an edit reaches its project before the model probe answers; readiness names non-CPU devices; a pipeline that cannot load says so

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-05 08:47:57 +03:00
co-authored by Claude Opus 5.5
parent fd35f4582d
commit 608b4dd566
7 changed files with 39 additions and 6 deletions
+2 -1
View File
@@ -15,6 +15,7 @@ import { createKeys } from "./shell/keys.js";
import { createDock } from "./shell/dock.js";
import { hostFor } from "./shell/mounts.js";
import paths from "./modules.js";
import { composer as c } from "./composer/composer.js";
// What the core offers a feature, by name. `on(fn)` hears every change; fn gets "open" when a
// different project was opened, "change" for an edit.
@@ -24,7 +25,7 @@ const on = (fn) => { heard.add(fn); return () => heard.delete(fn); };
const project = createProject({ keepConsistent: syncSeparated, onChange: () => say("change"), onOpen: () => say("open"), onError: (err) => console.warn(err) });
const pipeline = createPipelineState(api);
const generate = createGenerate({ pipeline });
linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "open") fn(); }) });
linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "open") fn(); }), say: (text) => c.toast.warn({ text }) });
const frame = build(document.getElementById("app"));
const selection = createSelection({ project });
selection.on(() => say("select"));
+1 -1
View File
@@ -28,7 +28,7 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
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(), true);
if (own || project.fresh) store(saved, pipeline.slots(), !project.fresh); // a new project's whole flag is a fact, not a guess
} else if (retries++ < 3) {
setTimeout(adopt, 5000);
} else {
+6 -3
View File
@@ -51,13 +51,14 @@ export function createPipelineState(API) {
async function refreshManifest() {
const file = currentModelFile(slots);
if (file === lastProbedFile) return;
if (file === lastProbedFile) return false;
const traits = await probeModelTraits(slots);
const manifest = await API.modules(traits ? traits.join(",") : undefined);
modulesById = {};
(manifest.modules || []).forEach((m) => { modulesById[m.id] = m; });
controlState = manifest.control || controlState;
if (traits !== undefined) lastProbedFile = file; // only once it worked: a failed probe is tried again
return true;
}
let incomplete = [];
let refused = [];
@@ -194,18 +195,20 @@ export function createPipelineState(API) {
refused = (res && res.refused) || [];
queueable = !!(res && res.queueable);
saveNotes = (res && res.notes) || [];
_changed(); // now: the project this edit was made in takes it, even if another opens during the probe
// A no-op for the common case (same file as last time) -- see
// refreshManifest()'s own guard. Only a Models & Pipeline edit that
// actually changes the loader's file does a second round trip here.
// Its own try/catch: a failure here is not "could not save" -- the
// edit above already landed -- so it must not overwrite saveNotes
// with a message about the wrong failure.
let fresh = false;
try {
await refreshManifest();
fresh = await refreshManifest();
} catch (e) {
console.warn(`[FunPack] could not refresh modules for the new model: ${e && e.message ? e.message : e}`);
}
_changed(); // after the modules: a listener sees the ones for this model
if (fresh && mine === epoch) _changed(); // again once a new model's modules are known: a listener sees those
} catch (e) {
saveNotes = [`Could not save: ${e && e.message ? e.message : e}`];
}
+10
View File
@@ -82,3 +82,13 @@ test("an Undo copy of the same project keeps receiving edits", async () => {
heard([{ ...slots[0], inputs: { ckpt_name: "b" } }]);
assert.equal(writes.length, 1);
});
test("a new project keeps the preset pipeline it inherits as whole, so it reopens exactly", async () => {
const writes = [];
let opened;
const project = { fresh: true, project: { id: "n", models: { slots: [] } }, setField: (k, v) => { writes.push(v); project.project[k] = v; } };
const pipeline = { adopt: async () => true, slots: () => slots, removedIds: () => ["clip"], unwiredMap: () => ({}), whole: () => true, subscribe: () => {} };
linkPipeline({ project, pipeline, onOpen: (fn) => { opened = fn; } });
await opened();
assert.equal(writes[0].whole, true);
});
+14
View File
@@ -356,3 +356,17 @@ test("a model file no module recognises says so, and why nothing is hidden", asy
await PS.ensureLoaded();
assert.match(PS.saveNotes().join(" "), /only \.safetensors.*every module is offered/);
});
test("a save reaches listeners before the model probe answers, so a project switched to meanwhile cannot take it", async () => {
const PS = withCheckpoint(load([]));
await PS.ensureLoaded();
let answer;
lastApi.probeFamily = () => new Promise((r) => { answer = r; });
let heard = 0;
PS.subscribe(() => { heard += 1; });
const saving = PS.save({ inputs: { model: { ckpt_name: "picked" } } });
await new Promise((r) => setTimeout(r, 0));
assert.ok(heard >= 1, "announced while the probe is still out");
answer({ detected: true, traits: [] });
await saving;
});
+3 -1
View File
@@ -46,8 +46,10 @@ def machine(disk_free_gb=None) -> List[dict]:
kind = device()
if kind == "mps":
out.append(row("ok", "Apple GPU (MPS): generates, slowly; large video models may not fit in its memory."))
elif kind != "cuda":
elif kind == "cpu":
out.append(row("warn", "ComfyUI runs on the CPU: generating is very slow, and only small models fit."))
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)
out.append(row("ok", f"{props.name}, {props.total_memory / 1024 ** 3:.0f} GB, sm_{props.major}{props.minor}"))
+3
View File
@@ -39,3 +39,6 @@ def test_the_row_names_the_device_comfyui_generates_on(monkeypatch):
assert not any("sageattention" in x["text"] for x in rows) # a CUDA-only library
monkeypatch.setattr(r, "device", lambda: "cpu") # ComfyUI started with --cpu, GPU or not
assert any(x["level"] == "warn" and "on the CPU" in x["text"] for x in r.machine())
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)