diff --git a/app/ui/boot.js b/app/ui/boot.js index d8db6ad6..dfe1e057 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -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")); diff --git a/app/ui/shell/pipeline_link.js b/app/ui/shell/pipeline_link.js index 765971af..3f8d0974 100644 --- a/app/ui/shell/pipeline_link.js +++ b/app/ui/shell/pipeline_link.js @@ -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 { diff --git a/app/ui/shell/pipeline_state.js b/app/ui/shell/pipeline_state.js index 08cb7842..a0663da0 100644 --- a/app/ui/shell/pipeline_state.js +++ b/app/ui/shell/pipeline_state.js @@ -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}`]; } diff --git a/app/ui/shell/tests/pipeline_link.test.js b/app/ui/shell/tests/pipeline_link.test.js index 3ae960cc..260dfe53 100644 --- a/app/ui/shell/tests/pipeline_link.test.js +++ b/app/ui/shell/tests/pipeline_link.test.js @@ -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); +}); diff --git a/app/ui/shell/tests/pipeline_state.test.js b/app/ui/shell/tests/pipeline_state.test.js index 447d968b..c78fe065 100644 --- a/app/ui/shell/tests/pipeline_state.test.js +++ b/app/ui/shell/tests/pipeline_state.test.js @@ -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; +}); diff --git a/core/readiness.py b/core/readiness.py index c8053399..05040e67 100644 --- a/core/readiness.py +++ b/core/readiness.py @@ -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}")) diff --git a/core/tests/test_readiness.py b/core/tests/test_readiness.py index 5b46d57a..4dfa671e 100644 --- a/core/tests/test_readiness.py +++ b/core/tests/test_readiness.py @@ -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)